Skip to content

AI Generation API

The generation layer crawls pages, classifies interactive elements, and emits Page Object Model + pytest test skeletons — optionally with Ollama integration.


PageCrawler

Crawls a web page and extracts interactive elements.

Source code in breadcrumb/generate/crawler.py
class PageCrawler:
    """Crawls a web page and extracts interactive elements."""

    def __init__(self, timeout_ms: int = 5000) -> None:
        self.timeout_ms = timeout_ms

    def crawl(self, url: str, page: Any | None = None) -> list[dict]:
        """Extract interactive elements from a live page via Playwright.

        If *page* is provided (a Playwright Page object), it will be used
        directly.  Otherwise a new browser context is created (and closed
        after extraction).
        """
        if page is not None:
            page.goto(url, timeout=self.timeout_ms)
            return cast(list[dict[Any, Any]], page.evaluate(_JS_EXTRACT))

        # Lazy import -- Playwright is optional
        from playwright.sync_api import sync_playwright  # pragma: no cover

        with sync_playwright() as pw:  # pragma: no cover
            browser = pw.chromium.launch()
            ctx = browser.new_context()
            p = ctx.new_page()
            p.goto(url, timeout=self.timeout_ms)
            elements = p.evaluate(_JS_EXTRACT)
            browser.close()
            return cast(list[dict[Any, Any]], elements)

    def crawl_static(self, html: str) -> list[dict]:
        """Parse a static HTML string and extract interactive elements.

        Uses Python's stdlib ``html.parser`` -- no Playwright required.
        """
        parser = _StaticHTMLExtractor()
        parser.feed(html)
        return parser.elements
crawl(url: str, page: Any | None = None) -> list[dict]

Extract interactive elements from a live page via Playwright.

If page is provided (a Playwright Page object), it will be used directly. Otherwise a new browser context is created (and closed after extraction).

Source code in breadcrumb/generate/crawler.py
def crawl(self, url: str, page: Any | None = None) -> list[dict]:
    """Extract interactive elements from a live page via Playwright.

    If *page* is provided (a Playwright Page object), it will be used
    directly.  Otherwise a new browser context is created (and closed
    after extraction).
    """
    if page is not None:
        page.goto(url, timeout=self.timeout_ms)
        return cast(list[dict[Any, Any]], page.evaluate(_JS_EXTRACT))

    # Lazy import -- Playwright is optional
    from playwright.sync_api import sync_playwright  # pragma: no cover

    with sync_playwright() as pw:  # pragma: no cover
        browser = pw.chromium.launch()
        ctx = browser.new_context()
        p = ctx.new_page()
        p.goto(url, timeout=self.timeout_ms)
        elements = p.evaluate(_JS_EXTRACT)
        browser.close()
        return cast(list[dict[Any, Any]], elements)
crawl_static(html: str) -> list[dict]

Parse a static HTML string and extract interactive elements.

Uses Python's stdlib html.parser -- no Playwright required.

Source code in breadcrumb/generate/crawler.py
def crawl_static(self, html: str) -> list[dict]:
    """Parse a static HTML string and extract interactive elements.

    Uses Python's stdlib ``html.parser`` -- no Playwright required.
    """
    parser = _StaticHTMLExtractor()
    parser.feed(html)
    return parser.elements

ElementClassifier

Heuristic-based classifier that assigns semantic roles to elements.

Source code in breadcrumb/generate/classifier.py
class ElementClassifier:
    """Heuristic-based classifier that assigns semantic roles to elements."""

    def classify(self, element: dict) -> str:
        """Return a semantic role string for *element*."""
        tag = (element.get("tag") or "").lower()
        input_type = (element.get("type") or "").lower()
        haystack = _text_fields(element)

        # Password input -- check before login so password_input wins
        if tag == "input" and input_type == "password":
            return "password_input"

        # Email input
        if tag == "input" and (input_type == "email" or _EMAIL_RE.search(haystack)):
            return "email_input"

        # Login-related
        if _LOGIN_RE.search(haystack):
            if tag == "form":
                return "login_form"
            if tag == "button" or (tag == "input" and input_type == "submit"):
                return "login_form"
            if tag == "input":
                return "text_input"
            return "login_form"

        # Search
        if _SEARCH_RE.search(haystack):
            if tag == "input":
                return "search"
            if tag == "button":
                return "search"
            return "search"

        # Submit buttons
        if tag == "button" or (tag == "input" and input_type == "submit"):
            if _SUBMIT_RE.search(haystack):
                return "submit"
            if input_type == "submit":
                return "submit"
            return "button"

        # Select / dropdown
        if tag == "select":
            return "dropdown"

        # Textarea
        if tag == "textarea":
            return "text_input"

        # Other inputs
        if tag == "input":
            if input_type in ("text", "", "tel", "url", "number"):
                return "text_input"
            if input_type == "checkbox":
                return "button"
            if input_type == "radio":
                return "button"
            return "text_input"

        # Form
        if tag == "form":
            return "form"

        # Links
        if tag == "a":
            return "navigation"

        return "unknown"

    def classify_page(self, elements: list[dict]) -> list[dict]:
        """Return a copy of *elements* with the ``role`` field set."""
        result = []
        for el in elements:
            classified = dict(el)
            classified["role"] = self.classify(el)
            result.append(classified)
        return result
classify(element: dict) -> str

Return a semantic role string for element.

Source code in breadcrumb/generate/classifier.py
def classify(self, element: dict) -> str:
    """Return a semantic role string for *element*."""
    tag = (element.get("tag") or "").lower()
    input_type = (element.get("type") or "").lower()
    haystack = _text_fields(element)

    # Password input -- check before login so password_input wins
    if tag == "input" and input_type == "password":
        return "password_input"

    # Email input
    if tag == "input" and (input_type == "email" or _EMAIL_RE.search(haystack)):
        return "email_input"

    # Login-related
    if _LOGIN_RE.search(haystack):
        if tag == "form":
            return "login_form"
        if tag == "button" or (tag == "input" and input_type == "submit"):
            return "login_form"
        if tag == "input":
            return "text_input"
        return "login_form"

    # Search
    if _SEARCH_RE.search(haystack):
        if tag == "input":
            return "search"
        if tag == "button":
            return "search"
        return "search"

    # Submit buttons
    if tag == "button" or (tag == "input" and input_type == "submit"):
        if _SUBMIT_RE.search(haystack):
            return "submit"
        if input_type == "submit":
            return "submit"
        return "button"

    # Select / dropdown
    if tag == "select":
        return "dropdown"

    # Textarea
    if tag == "textarea":
        return "text_input"

    # Other inputs
    if tag == "input":
        if input_type in ("text", "", "tel", "url", "number"):
            return "text_input"
        if input_type == "checkbox":
            return "button"
        if input_type == "radio":
            return "button"
        return "text_input"

    # Form
    if tag == "form":
        return "form"

    # Links
    if tag == "a":
        return "navigation"

    return "unknown"
classify_page(elements: list[dict]) -> list[dict]

Return a copy of elements with the role field set.

Source code in breadcrumb/generate/classifier.py
def classify_page(self, elements: list[dict]) -> list[dict]:
    """Return a copy of *elements* with the ``role`` field set."""
    result = []
    for el in elements:
        classified = dict(el)
        classified["role"] = self.classify(el)
        result.append(classified)
    return result

TestCodeGenerator

Generates Page Object Model classes and pytest test stubs.

Source code in breadcrumb/generate/codegen.py
class TestCodeGenerator:
    """Generates Page Object Model classes and pytest test stubs."""

    def __init__(self, ollama_model: str | None = None) -> None:
        self._ollama_model = ollama_model

    def generate_page_object(self, page_name: str, elements: list[dict]) -> str:
        """Generate a Page Object Model class as a Python string."""
        cls_name = _to_class_name(page_name)

        # Filter to interactive elements only (skip forms, they are containers)
        interactive = [e for e in elements if e.get("tag") in ("button", "input", "select", "textarea", "a")]

        lines = [
            f"class {cls_name}:",
            f'    """Page object for the {page_name} page."""',
            "",
            "    def __init__(self, page):",
            "        self.page = page",
        ]

        seen_vars: set[str] = set()
        var_map: list[tuple[str, dict]] = []

        for el in interactive:
            var = _element_var_name(el)
            if var in seen_vars:
                var = var + "_" + (el.get("tag") or "el")
            seen_vars.add(var)
            selector = el.get("selector", el.get("tag", ""))
            lines.append(f"        self.{var} = page.locator({selector!r})")
            var_map.append((var, el))

        if not interactive:
            lines.append("        pass")

        # Generate interaction methods
        for var, el in var_map:
            method = _method_name(el)
            # Avoid duplicate method names by using var-based name
            parts = method.split("_", 1)
            method = parts[0] + "_" + var
            params = _method_params(el)
            body = _method_body(el, var)

            lines.append("")
            lines.append(f"    def {method}(self{params}) -> None:")
            lines.append(f"        {body}")

        return "\n".join(lines) + "\n"

    def generate_test_file(self, page_name: str, elements: list[dict], page_url: str = "") -> str:
        """Generate a pytest test file with basic test stubs."""
        cls_name = _to_class_name(page_name)

        # Try to get enriched docstring from Ollama
        enrichment = None
        if self._ollama_model:
            enrichment = _try_ollama_enrich(self._ollama_model, page_name, elements)

        test_docstring = "Smoke test: verify key elements are visible."
        if enrichment and enrichment.get("test_docstring"):
            test_docstring = enrichment["test_docstring"]

        url_line = f'    URL = "{page_url}"' if page_url else '    URL = ""'

        lines = [
            f'"""Auto-generated tests for the {page_name} page."""',
            "",
            "import pytest",
            "from playwright.sync_api import Page, expect",
            "",
            "",
        ]

        # Generate a smoke test that checks visibility of key elements
        interactive = [e for e in elements if e.get("tag") in ("button", "input", "select", "textarea", "a")]

        lines.append(f"class Test{cls_name}:")
        lines.append(f'    """{test_docstring}"""')
        lines.append("")
        lines.append(url_line)
        lines.append("")

        # Test: page loads
        lines.append("    def test_page_loads(self, page: Page) -> None:")
        lines.append('        """Verify the page loads successfully."""')
        if page_url:
            lines.append("        page.goto(self.URL)")
        else:
            lines.append("        pass  # TODO: set URL")
        lines.append("")

        # Per-element tests
        seen_tests: set[str] = set()
        for el in interactive:
            var = _element_var_name(el)
            tag = el.get("tag", "")
            selector = el.get("selector", tag)
            test_name = f"test_{var}_is_visible"
            if test_name in seen_tests:
                test_name = test_name + "_" + tag
            seen_tests.add(test_name)

            lines.append(f"    def {test_name}(self, page: Page) -> None:")
            lines.append(f'        """Verify {var} element is visible."""')
            if page_url:
                lines.append("        page.goto(self.URL)")
            lines.append(f"        expect(page.locator({selector!r})).to_be_visible()")
            lines.append("")

        # If there are fillable elements, add a fill test
        fillable = [e for e in interactive if e.get("tag") in ("input", "textarea")]
        if fillable:
            lines.append("    def test_fill_inputs(self, page: Page) -> None:")
            lines.append('        """Verify inputs can be filled."""')
            if page_url:
                lines.append("        page.goto(self.URL)")
            for el in fillable[:5]:  # Limit to first 5
                selector = el.get("selector", "")
                input_type = (el.get("type") or "text").lower()
                if input_type == "email":
                    val = "test@example.com"
                elif input_type == "password":
                    val = "password123"
                elif input_type == "checkbox":
                    continue
                else:
                    val = "test value"
                lines.append(f"        page.locator({selector!r}).fill({val!r})")
            lines.append("")

        return "\n".join(lines)
generate_page_object(page_name: str, elements: list[dict]) -> str

Generate a Page Object Model class as a Python string.

Source code in breadcrumb/generate/codegen.py
def generate_page_object(self, page_name: str, elements: list[dict]) -> str:
    """Generate a Page Object Model class as a Python string."""
    cls_name = _to_class_name(page_name)

    # Filter to interactive elements only (skip forms, they are containers)
    interactive = [e for e in elements if e.get("tag") in ("button", "input", "select", "textarea", "a")]

    lines = [
        f"class {cls_name}:",
        f'    """Page object for the {page_name} page."""',
        "",
        "    def __init__(self, page):",
        "        self.page = page",
    ]

    seen_vars: set[str] = set()
    var_map: list[tuple[str, dict]] = []

    for el in interactive:
        var = _element_var_name(el)
        if var in seen_vars:
            var = var + "_" + (el.get("tag") or "el")
        seen_vars.add(var)
        selector = el.get("selector", el.get("tag", ""))
        lines.append(f"        self.{var} = page.locator({selector!r})")
        var_map.append((var, el))

    if not interactive:
        lines.append("        pass")

    # Generate interaction methods
    for var, el in var_map:
        method = _method_name(el)
        # Avoid duplicate method names by using var-based name
        parts = method.split("_", 1)
        method = parts[0] + "_" + var
        params = _method_params(el)
        body = _method_body(el, var)

        lines.append("")
        lines.append(f"    def {method}(self{params}) -> None:")
        lines.append(f"        {body}")

    return "\n".join(lines) + "\n"
generate_test_file(page_name: str, elements: list[dict], page_url: str = '') -> str

Generate a pytest test file with basic test stubs.

Source code in breadcrumb/generate/codegen.py
def generate_test_file(self, page_name: str, elements: list[dict], page_url: str = "") -> str:
    """Generate a pytest test file with basic test stubs."""
    cls_name = _to_class_name(page_name)

    # Try to get enriched docstring from Ollama
    enrichment = None
    if self._ollama_model:
        enrichment = _try_ollama_enrich(self._ollama_model, page_name, elements)

    test_docstring = "Smoke test: verify key elements are visible."
    if enrichment and enrichment.get("test_docstring"):
        test_docstring = enrichment["test_docstring"]

    url_line = f'    URL = "{page_url}"' if page_url else '    URL = ""'

    lines = [
        f'"""Auto-generated tests for the {page_name} page."""',
        "",
        "import pytest",
        "from playwright.sync_api import Page, expect",
        "",
        "",
    ]

    # Generate a smoke test that checks visibility of key elements
    interactive = [e for e in elements if e.get("tag") in ("button", "input", "select", "textarea", "a")]

    lines.append(f"class Test{cls_name}:")
    lines.append(f'    """{test_docstring}"""')
    lines.append("")
    lines.append(url_line)
    lines.append("")

    # Test: page loads
    lines.append("    def test_page_loads(self, page: Page) -> None:")
    lines.append('        """Verify the page loads successfully."""')
    if page_url:
        lines.append("        page.goto(self.URL)")
    else:
        lines.append("        pass  # TODO: set URL")
    lines.append("")

    # Per-element tests
    seen_tests: set[str] = set()
    for el in interactive:
        var = _element_var_name(el)
        tag = el.get("tag", "")
        selector = el.get("selector", tag)
        test_name = f"test_{var}_is_visible"
        if test_name in seen_tests:
            test_name = test_name + "_" + tag
        seen_tests.add(test_name)

        lines.append(f"    def {test_name}(self, page: Page) -> None:")
        lines.append(f'        """Verify {var} element is visible."""')
        if page_url:
            lines.append("        page.goto(self.URL)")
        lines.append(f"        expect(page.locator({selector!r})).to_be_visible()")
        lines.append("")

    # If there are fillable elements, add a fill test
    fillable = [e for e in interactive if e.get("tag") in ("input", "textarea")]
    if fillable:
        lines.append("    def test_fill_inputs(self, page: Page) -> None:")
        lines.append('        """Verify inputs can be filled."""')
        if page_url:
            lines.append("        page.goto(self.URL)")
        for el in fillable[:5]:  # Limit to first 5
            selector = el.get("selector", "")
            input_type = (el.get("type") or "text").lower()
            if input_type == "email":
                val = "test@example.com"
            elif input_type == "password":
                val = "password123"
            elif input_type == "checkbox":
                continue
            else:
                val = "test value"
            lines.append(f"        page.locator({selector!r}).fill({val!r})")
        lines.append("")

    return "\n".join(lines)