#!/usr/bin/env python3
"""Deterministic ThemeFactory runtime verifier.

One process owns one disposable WordPress/MariaDB/Chromium fixture and executes a
semantic shard.  No test may fabricate pass evidence: each requested ID has an
explicit implementation below and every receipt includes raw assertion details.
"""
from __future__ import annotations

import argparse
import base64
import contextlib
import hashlib
import http.cookiejar
import http.server
import json
import math
import os
import random
import re
import shutil
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zlib
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Any, Callable

ROOT = Path(__file__).resolve().parents[2]
AXE_JS = ROOT / "ci" / "runtime" / "axe.min.js"
POLICY = json.loads((ROOT / "ci" / "policy.yaml").read_text())
LEDGER = ROOT / "ci" / "themefactory-tests.jsonl"

class TestFailure(RuntimeError):
    pass


def require(cond: bool, message: str) -> None:
    if not cond:
        raise TestFailure(message)


def sh(cmd: list[str], *, cwd: Path | None = None, timeout: int = 120, check: bool = True) -> subprocess.CompletedProcess[str]:
    cp = subprocess.run(cmd, cwd=cwd or ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout, check=False)
    if check and cp.returncode:
        raise TestFailure(f"command failed ({cp.returncode}): {' '.join(cmd)}\n{cp.stdout[-5000:]}")
    return cp


def sha256(path: Path) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        for chunk in iter(lambda: f.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def zip_content_tree(path: Path) -> dict[str, str]:
    """Return archive entry content identities independent of ZIP encoding details.

    Python/zlib versions can produce different DEFLATE byte streams for the same
    logical archive. Release verification therefore compares the shipped tree,
    then installs the tracked dist bytes as the exact artifact under test.
    """
    result: dict[str, str] = {}
    with zipfile.ZipFile(path) as archive:
        for info in archive.infolist():
            if info.is_dir():
                continue
            require(info.filename not in result, f"duplicate archive entry: {info.filename}")
            result[info.filename] = hashlib.sha256(archive.read(info)).hexdigest()
    return result


def free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return int(s.getsockname()[1])


def http_json(method: str, url: str, payload: Any | None = None, timeout: int = 30) -> Any:
    data = None if payload is None else json.dumps(payload).encode()
    req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json;charset=UTF-8"})
    with urllib.request.urlopen(req, timeout=timeout) as res:
        raw = res.read()
    return json.loads(raw) if raw else None


class WebDriver:
    def __init__(self, endpoint: str):
        self.endpoint = endpoint.rstrip("/")
        self.session: str | None = None
        self.browser_logs: list[dict[str, Any]] = []

    def open(self, width: int = 1440, height: int = 900) -> None:
        payload = {
            "capabilities": {"alwaysMatch": {
                "browserName": "chrome",
                "goog:chromeOptions": {"args": ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", f"--window-size={width},{height}" ]},
                "goog:loggingPrefs": {"browser": "ALL", "performance": "ALL"},
                "pageLoadStrategy": "normal",
            }}
        }
        data = http_json("POST", self.endpoint + "/session", payload)
        value = data.get("value", data)
        self.session = value.get("sessionId") or data.get("sessionId")
        require(bool(self.session), f"could not create WebDriver session: {data}")
        self.set_window(width, height)

    def close(self) -> None:
        if self.session:
            with contextlib.suppress(Exception):
                http_json("DELETE", f"{self.endpoint}/session/{self.session}")
            self.session = None

    def _url(self, suffix: str) -> str:
        require(bool(self.session), "webdriver session not open")
        return f"{self.endpoint}/session/{self.session}{suffix}"

    def navigate(self, url: str) -> None:
        http_json("POST", self._url("/url"), {"url": url}, timeout=60)

    def current_url(self) -> str:
        return str(http_json("GET", self._url("/url"))["value"])

    def execute(self, script: str, args: list[Any] | None = None) -> Any:
        data = http_json("POST", self._url("/execute/sync"), {"script": script, "args": args or []}, timeout=60)
        return data.get("value")

    def execute_async(self, script: str, args: list[Any] | None = None) -> Any:
        data = http_json("POST", self._url("/execute/async"), {"script": script, "args": args or []}, timeout=90)
        return data.get("value")

    def set_window(self, width: int, height: int) -> None:
        http_json("POST", self._url("/window/rect"), {"x": 0, "y": 0, "width": width, "height": height})

    def screenshot(self) -> bytes:
        data = http_json("GET", self._url("/screenshot"))
        return base64.b64decode(data["value"])

    def logs(self, kind: str = "browser") -> list[dict[str, Any]]:
        try:
            data = http_json("POST", self._url("/log"), {"type": kind})
            return list(data.get("value") or [])
        except Exception:
            return []

    def wait_js(self, expression: str, timeout: float = 20, interval: float = 0.2) -> Any:
        deadline = time.time() + timeout
        last = None
        while time.time() < deadline:
            try:
                last = self.execute(f"return ({expression});")
                if last:
                    return last
            except Exception:
                pass
            time.sleep(interval)
        raise TestFailure(f"timeout waiting for JS expression: {expression}; last={last!r}")


@dataclass
class RuntimeContext:
    theme: str
    contract: dict[str, Any]
    requested_ids: list[str]
    engine: str = "docker"
    artifact: Path | None = None
    work: Path | None = None
    prefix: str = ""
    network: str = ""
    volume: str = ""
    db_name: str = ""
    wp_name: str = ""
    selenium_name: str = ""
    wp_port: int = 0
    selenium_port: int = 0
    base_url: str = ""
    host_url: str = ""
    browser: WebDriver | None = None
    admin_user: str = "tfadmin"
    admin_password: str = "tfpass-Deterministic-2026"
    routes: list[str] = field(default_factory=list)
    post_types: dict[str, dict[str, Any]] = field(default_factory=dict)
    imported: bool = False
    fixture_started: float = 0.0
    rewrite_before_requests: str = ""
    reference_name: str = ""
    reference_started: bool = False
    ui_cache_ready: bool = False
    ui_results: dict[str, list[Any]] = field(default_factory=dict)
    ui_traversals: int = 0

    def cmd(self, *args: str, timeout: int = 120, check: bool = True) -> subprocess.CompletedProcess[str]:
        return sh([self.engine, *args], timeout=timeout, check=check)

    def ensure_image(self, image: str) -> None:
        inspect = self.cmd("image", "inspect", image, timeout=30, check=False)
        if inspect.returncode == 0:
            return
        timeout = int(POLICY.get("runtime", {}).get("image_pull_timeout_seconds", 600))
        self.cmd("pull", image, timeout=timeout)
        verify = self.cmd("image", "inspect", image, timeout=30, check=False)
        require(verify.returncode == 0, f"runtime image unavailable after acquisition: {image}")

    def wp(self, *args: str, timeout: int = 120, check: bool = True) -> str:
        image = str(POLICY["runtime"].get("wp_cli_image", "docker.io/library/wordpress:cli-php8.3"))
        cmd = [self.engine, "run", "--rm", "--user", "0:0", "--network", self.network, "-v", f"{self.volume}:/var/www/html", "-e", "WORDPRESS_DB_HOST=" + self.db_name, "-e", "WORDPRESS_DB_USER=wp", "-e", "WORDPRESS_DB_PASSWORD=wp", "-e", "WORDPRESS_DB_NAME=wordpress", image, "wp", "--allow-root", *args]
        # WP-CLI data commands are parsed by callers. Keep stderr separate so PHP
        # warnings/deprecations can never be mistaken for JSON, IDs, paths, or URLs.
        cp = subprocess.run(cmd, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, check=False)
        if check and cp.returncode:
            combined = (cp.stdout + ("\n" if cp.stdout and cp.stderr else "") + cp.stderr)[-5000:]
            raise TestFailure(f"command failed ({cp.returncode}): {' '.join(cmd)}\n{combined}")
        return cp.stdout.strip()

    def wp_eval(self, php: str, *, timeout: int = 120, check: bool = True) -> str:
        return self.wp("eval", php, timeout=timeout, check=check)

    def wait_http(self, url: str, timeout: int = 60) -> None:
        deadline = time.time() + timeout
        last = None
        while time.time() < deadline:
            try:
                with urllib.request.urlopen(url, timeout=3) as res:
                    if 200 <= res.status < 500:
                        return
            except Exception as exc:
                last = exc
            time.sleep(0.4)
        raise TestFailure(f"HTTP endpoint did not become ready: {url}; last={last}")

    def setup(self) -> None:
        self.fixture_started = time.time()
        self.work = Path(tempfile.mkdtemp(prefix=f"themefactory-{self.theme}-"))
        token = uuid.uuid4().hex[:10]
        self.prefix = f"tf-{re.sub('[^a-z0-9]+','-',self.theme.lower())}-{token}"
        self.network = self.prefix + "-net"
        self.volume = self.prefix + "-html"
        self.db_name = self.prefix + "-db"
        self.wp_name = self.prefix + "-wp"
        self.selenium_name = self.prefix + "-chrome"
        self.wp_port = free_port(); self.selenium_port = free_port()
        self.host_url = f"http://127.0.0.1:{self.wp_port}"
        self.base_url = f"http://{self.wp_name}"
        db_image = str(POLICY["runtime"].get("database_image", "docker.io/library/mariadb:11.8"))
        wp_image = str(POLICY["runtime"].get("wordpress_image", "docker.io/library/wordpress:7.1-php8.3-apache"))
        wp_cli_image = str(POLICY["runtime"].get("wp_cli_image", "docker.io/library/wordpress:cli-php8.3"))
        chrome_image = str(POLICY["runtime"].get("selenium_image", "docker.io/selenium/standalone-chromium:4.35.0-20250828"))
        # Fresh ARC DIND daemons have an empty image graph. Pull digest-pinned images
        # explicitly with an acquisition timeout rather than coupling registry latency
        # to `docker run`, which can leave a half-created container on timeout.
        for image in (db_image, wp_image, wp_cli_image, chrome_image):
            self.ensure_image(image)
        self.cmd("network", "create", self.network)
        self.cmd("volume", "create", self.volume)
        self.cmd("run", "-d", "--name", self.db_name, "--network", self.network,
                 "-e", "MARIADB_DATABASE=wordpress", "-e", "MARIADB_USER=wp", "-e", "MARIADB_PASSWORD=wp", "-e", "MARIADB_ROOT_PASSWORD=root",
                 db_image, "--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci", timeout=180)
        self.cmd("run", "-d", "--name", self.wp_name, "--network", self.network, "-p", f"127.0.0.1:{self.wp_port}:80", "-v", f"{self.volume}:/var/www/html",
                 "-e", f"WORDPRESS_DB_HOST={self.db_name}", "-e", "WORDPRESS_DB_USER=wp", "-e", "WORDPRESS_DB_PASSWORD=wp", "-e", "WORDPRESS_DB_NAME=wordpress",
                 wp_image, timeout=180)
        self.wait_http(self.host_url, timeout=90)
        # Install WordPress. The pretty-permalink structure is established BEFORE theme activation. No flush is allowed after activation in TF-INSTALL-001.
        self.wp("core", "install", f"--url={self.base_url}", "--title=ThemeFactory CI", f"--admin_user={self.admin_user}", f"--admin_password={self.admin_password}", "--admin_email=ci@example.invalid", "--skip-email", timeout=180)
        self.wp("rewrite", "structure", "/%postname%/", "--hard", timeout=120)
        self.wp("option", "update", "timezone_string", "UTC")
        self.wp("option", "update", "blogdescription", "Deterministic ThemeFactory fixture")
        # Importer/editor checks authenticate this same browser session. Keep frontend
        # geometry/screenshots public-equivalent by disabling the logged-in toolbar.
        self.wp("user", "meta", "update", self.admin_user, "show_admin_bar_front", "false")
        self.artifact = self._candidate_zip()
        # Stream exact candidate bytes into the mounted WordPress volume. `docker/podman cp`
        # can target container overlay semantics and is not a reliable cross-container volume handoff.
        proc = subprocess.run(
            [self.engine, "exec", "-i", self.wp_name, "sh", "-c", "cat > /var/www/html/themefactory-candidate.zip"],
            input=self.artifact.read_bytes(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False
        )
        require(proc.returncode == 0, f"candidate artifact injection failed: {proc.stdout.decode(errors='replace')}")
        self.wp("theme", "install", "/var/www/html/themefactory-candidate.zip", "--force", "--activate", timeout=180)
        self._discover_post_types()
        self.rewrite_before_requests = self.wp("option", "get", "rewrite_rules", check=False)
        # Browser is a separate container on the same Docker network. WordPress self-URL remains loopback for wp-cli/admin internals;
        # browser accesses host-published port through Selenium's host-gateway alias.
        self.cmd("run", "-d", "--name", self.selenium_name, "--network", self.network, "--shm-size=2g", "-p", f"127.0.0.1:{self.selenium_port}:4444",
                 "--add-host", "host.docker.internal:host-gateway", chrome_image, timeout=180)
        self.wait_http(f"http://127.0.0.1:{self.selenium_port}/status", timeout=90)
        self.browser = WebDriver(f"http://127.0.0.1:{self.selenium_port}")
        self.browser.open()
        # WordPress, WP-CLI and Selenium share one canonical network hostname.
        self.browser_base = self.base_url
        self.routes = self.discover_routes()

    def _candidate_zip(self) -> Path:
        dist = ROOT / "dist" / f"{self.theme}.zip"
        releaseish = any(x.startswith("TF-PKG-01") or x.startswith("TF-REL-") for x in self.requested_ids)
        # For all runtime verification build an exact deterministic ZIP from current source. In release cells, demand equality with tracked dist.
        container = self.work / "container"; container.mkdir()
        source = ROOT / "themes" / self.theme
        target = container / self.theme
        shutil.copytree(source, target)
        for rel in POLICY.get("non_shipping_theme_files", []):
            path = target / PurePosixPath(str(rel))
            require(path.resolve().is_relative_to(target.resolve()), f"unsafe non-shipping theme path: {rel}")
            if path.is_dir():
                shutil.rmtree(path)
            elif path.exists():
                path.unlink()
        candidate = self.work / f"{self.theme}.zip"
        sh([sys.executable, str(ROOT / "tools" / "theme_factory.py"), "pack", str(container), str(candidate)], timeout=180)
        if releaseish and dist.exists():
            require(zip_content_tree(candidate) == zip_content_tree(dist), f"release candidate tree does not equal dist/{self.theme}.zip")
            shutil.copy2(dist, candidate)
        return candidate

    def _discover_post_types(self) -> None:
        php = r'''$out=[]; foreach(get_post_types([], 'objects') as $n=>$o){ if(in_array($n,['revision','nav_menu_item','custom_css','customize_changeset','wp_template','wp_template_part','wp_global_styles','wp_navigation','wp_font_family','wp_font_face'],true)) continue; $out[$n]=['public'=>(bool)$o->public,'has_archive'=>$o->has_archive,'rewrite'=>$o->rewrite,'rest'=>(bool)$o->show_in_rest]; } echo wp_json_encode($out);'''
        raw = self.wp_eval(php)
        try: self.post_types = json.loads(raw or "{}")
        except Exception: self.post_types = {}

    def discover_routes(self) -> list[str]:
        configured = self.contract.get("runtime", {}).get("routes", "auto")
        if isinstance(configured, list):
            return sorted(dict.fromkeys(configured))
        php = r'''$r=['/']; foreach(get_posts(['post_type'=>get_post_types(['public'=>true]),'post_status'=>'publish','posts_per_page'=>80]) as $p){ $u=get_permalink($p); if($u){$path=wp_parse_url($u,PHP_URL_PATH); if($path)$r[]=$path;} } foreach(get_post_types(['public'=>true],'objects') as $o){ if($o->has_archive){$u=get_post_type_archive_link($o->name); if($u){$path=wp_parse_url($u,PHP_URL_PATH); if($path)$r[]=$path;}} } echo wp_json_encode(array_values(array_unique($r)));'''
        raw = self.wp_eval(php)
        try: routes=json.loads(raw)
        except Exception: routes=["/"]
        # Bound per-shard broad route matrix. Specialized tests query their own objects.
        return sorted(dict.fromkeys(routes))[:40]

    def http_fetch(self, path: str, *, follow: bool = True, timeout: int = 15) -> tuple[int, str]:
        url = path if path.startswith(("http://", "https://")) else self.base_url + (path if path.startswith("/") else "/" + path)
        args = ["exec", self.wp_name, "curl", "-sS", "--max-time", str(timeout)]
        if follow:
            args.append("-L")
        args += ["-w", "\n%{http_code}", url]
        cp = self.cmd(*args, timeout=timeout + 10, check=False)
        out = cp.stdout.rstrip("\n")
        if "\n" in out:
            body, code_s = out.rsplit("\n", 1)
        else:
            body, code_s = "", out
        try:
            code = int(code_s.strip())
        except ValueError:
            code = 0
        return code, body

    def http_status(self, path: str, *, follow: bool = True, timeout: int = 15) -> int:
        url = path if path.startswith(("http://", "https://")) else self.base_url + (path if path.startswith("/") else "/" + path)
        args = ["exec", self.wp_name, "curl", "-sS", "--max-time", str(timeout), "-o", "/dev/null"]
        if follow:
            args.append("-L")
        args += ["-w", "%{http_code}", url]
        cp = self.cmd(*args, timeout=timeout + 10, check=False)
        try:
            return int(cp.stdout.strip())
        except ValueError:
            return 0

    def http_body(self, path: str, *, follow: bool = True, timeout: int = 15) -> str:
        return self.http_fetch(path, follow=follow, timeout=timeout)[1]

    def browser_url(self, path: str) -> str:
        if path.startswith("http://") or path.startswith("https://"):
            parsed=urllib.parse.urlsplit(path); return self.browser_base + (parsed.path or "/") + (("?"+parsed.query) if parsed.query else "")
        return self.browser_base + (path if path.startswith("/") else "/"+path)

    def login_admin(self) -> None:
        assert self.browser
        self.browser.navigate(self.browser_url("/wp-login.php"))
        self.browser.execute("document.getElementById('user_login').value=arguments[0]; document.getElementById('user_pass').value=arguments[1]; document.getElementById('wp-submit').click();", [self.admin_user,self.admin_password])
        self.browser.wait_js("location.pathname.indexOf('/wp-admin/')>=0 || document.body.classList.contains('wp-admin')", timeout=20)

    def import_demo_browser(self) -> None:
        if not self.contract.get("features",{}).get("demo_import"):
            return
        assert self.browser
        info=self.contract.get("demo_import",{})
        path=info.get("admin_path")
        self.login_admin()
        if path:
            self.browser.navigate(self.browser_url("/wp-admin/"+path.lstrip("/")))
        else:
            # Menu registration is an authenticated wp-admin concern. Discover it in
            # the real admin DOM rather than synthesizing `admin_menu` via WP-CLI.
            self.browser.navigate(self.browser_url("/wp-admin/"))
            self.browser.wait_js("document.querySelector('#adminmenu')", timeout=20)
            clicked=self.browser.execute(r'''const links=[...document.querySelectorAll('#menu-appearance .wp-submenu a[href]')]; const a=links.find(x=>/import/i.test(x.textContent||'')); if(!a)return false; a.click(); return true;''')
            require(clicked, "demo import Appearance submenu link not found")
            self.browser.wait_js("document.readyState==='complete'", timeout=30)

        button_selector=info.get("button_selector") or "form input[type=submit], form button[type=submit], .button-primary"
        probe=self.browser.execute(r'''const e=document.querySelector(arguments[0]); if(!e)return {found:false}; const f=e.closest('form'); return {found:true,form:!!f,action:f?(f.getAttribute('action')||''):null};''',[button_selector]) or {}
        require(probe.get("found"), f"demo import button not found at {path}: {button_selector}")

        # A pre-click marker is our transition oracle. Form importers must replace the
        # document; AJAX importers must mutate it. This prevents an already-complete
        # old document or pre-existing completion text from being accepted as proof.
        marker="tf-import-"+uuid.uuid4().hex
        armed=self.browser.execute(r'''window.__themefactoryImportMarker=arguments[0]; window.__themefactoryImportMutated=false; if(window.__themefactoryImportObserver){window.__themefactoryImportObserver.disconnect();} window.__themefactoryImportObserver=new MutationObserver(()=>{window.__themefactoryImportMutated=true;}); window.__themefactoryImportObserver.observe(document.documentElement,{subtree:true,childList:true,attributes:true,characterData:true}); return true;''',[marker])
        require(armed, "could not arm demo-import transition observer")
        clicked=self.browser.execute("var e=document.querySelector(arguments[0]); if(!e)return false; e.click(); return true;",[button_selector])
        require(clicked, f"demo import button disappeared before click: {button_selector}")

        if probe.get("form"):
            # Default form importers are complete only after the old document is gone.
            self.browser.wait_js(f"window.__themefactoryImportMarker !== {json.dumps(marker)}", timeout=120)
        else:
            # AJAX importers stay on the same document; require observable DOM work.
            self.browser.wait_js(f"window.__themefactoryImportMarker !== {json.dumps(marker)} || window.__themefactoryImportMutated === true", timeout=120)

        self.browser.wait_js("document.readyState==='complete'",timeout=120)
        completion=info.get("completion_selector")
        completion_text=info.get("completion_text")
        if completion:
            self.browser.wait_js(f"document.querySelector({json.dumps(completion)})",timeout=120)
        elif completion_text:
            needle=json.dumps(completion_text)
            self.browser.wait_js(f"document.body.innerText.indexOf({needle})>=0",timeout=120)
        elif not probe.get("form"):
            # AJAX importers without a declared completion marker must at least settle
            # out of common busy states after the observed mutation.
            self.browser.wait_js("!document.querySelector('[aria-busy=true], progress:not([hidden])')", timeout=120)

        self.imported=True
        self._discover_post_types(); self.routes=self.discover_routes()

    def snapshot_wp_state(self) -> dict[str, Any]:
        php=r'''$o=[]; foreach(get_post_types([], 'names') as $pt){ if(in_array($pt,['revision','customize_changeset'],true))continue; $c=wp_count_posts($pt); $sum=0; foreach((array)$c as $st=>$n){if(!in_array($st,['trash','auto-draft'],true))$sum+=(int)$n;} if($sum)$o['post_types'][$pt]=$sum;} $o['menus']=wp_get_nav_menus(); $o['menu_items']=0; foreach($o['menus'] as $m){$o['menu_items']+=count(wp_get_nav_menu_items($m->term_id));} $o['terms']=[]; foreach(get_taxonomies([], 'names') as $tx){$c=wp_count_terms(['taxonomy'=>$tx,'hide_empty'=>false]); if(!is_wp_error($c)&&$c)$o['terms'][$tx]=(int)$c;} $o['settings']=['show_on_front'=>get_option('show_on_front'),'page_on_front'=>(int)get_option('page_on_front'),'page_for_posts'=>(int)get_option('page_for_posts'),'permalink_structure'=>get_option('permalink_structure')]; echo wp_json_encode($o);'''
        return json.loads(self.wp_eval(php))

    def cleanup(self) -> None:
        if self.browser: self.browser.close()
        for name in (self.reference_name,self.selenium_name,self.wp_name,self.db_name):
            if name:
                with contextlib.suppress(Exception): self.cmd("rm","-f",name,check=False,timeout=30)
        if self.volume:
            with contextlib.suppress(Exception): self.cmd("volume","rm","-f",self.volume,check=False,timeout=30)
        if self.network:
            with contextlib.suppress(Exception): self.cmd("network","rm",self.network,check=False,timeout=30)
        if self.work: shutil.rmtree(self.work,ignore_errors=True)



def php_call_owners(path: Path, call_name: str) -> list[str]:
    """Return named PHP function owners for calls using PHP's own tokenizer."""
    code = r'''$src=file_get_contents($argv[1]);$call=$argv[2];$ts=token_get_all($src);$brace=0;$pending=null;$expect=false;$stack=[];$owners=[];for($i=0,$n=count($ts);$i<$n;$i++){ $t=$ts[$i]; if(is_array($t)){ if($t[0]===T_FUNCTION){$expect=true;$pending=null;continue;} if($expect && $t[0]===T_STRING){$pending=$t[1];$expect=false;continue;} if($t[0]===T_STRING && $t[1]===$call){$owners[]=count($stack)?$stack[count($stack)-1]['name']:'<top-level>';}} else { if($expect && $t==='('){$expect=false;$pending=null;} if($t==='{'){ $brace++; if($pending!==null){$stack[]=['name'=>$pending,'depth'=>$brace];$pending=null;} } elseif($t==='}'){ if(count($stack) && $stack[count($stack)-1]['depth']===$brace){array_pop($stack);} $brace--; } } } echo json_encode($owners);'''
    cp = sh(["php", "-r", code, str(path), call_name], timeout=30, check=False)
    require(cp.returncode == 0, f"PHP token scan failed for {path}: {cp.stdout[-2000:]}")
    try:
        value = json.loads(cp.stdout or "[]")
    except json.JSONDecodeError as exc:
        raise TestFailure(f"PHP token scan returned invalid JSON for {path}: {exc}: {cp.stdout[-1000:]}") from exc
    return [str(x) for x in value]

# ---- PNG comparison (stdlib-only, Chrome screenshot formats) ----
def decode_png(data: bytes) -> tuple[int,int,bytes]:
    require(data[:8]==b"\x89PNG\r\n\x1a\n", "not PNG")
    pos=8; w=h=0; color=0; depth=0; interlace=0; raw=b""
    while pos+12<=len(data):
        n=struct.unpack(">I",data[pos:pos+4])[0]; typ=data[pos+4:pos+8]; body=data[pos+8:pos+8+n]; pos+=12+n
        if typ==b"IHDR": w,h,depth,color,_,_,interlace=struct.unpack(">IIBBBBB",body)
        elif typ==b"IDAT": raw+=body
        elif typ==b"IEND": break
    require(depth==8 and interlace==0 and color in {2,6}, f"unsupported PNG format depth={depth} color={color} interlace={interlace}")
    bpp=3 if color==2 else 4; scan=zlib.decompress(raw); stride=w*bpp; out=bytearray(h*stride); src=0
    for y in range(h):
        ft=scan[src]; src+=1; row=bytearray(scan[src:src+stride]); src+=stride
        prev=out[(y-1)*stride:y*stride] if y else bytes(stride)
        for x in range(stride):
            a=row[x-bpp] if x>=bpp else 0; b=prev[x] if y else 0; c=prev[x-bpp] if y and x>=bpp else 0
            if ft==1: row[x]=(row[x]+a)&255
            elif ft==2: row[x]=(row[x]+b)&255
            elif ft==3: row[x]=(row[x]+((a+b)//2))&255
            elif ft==4:
                p=a+b-c; pa=abs(p-a); pb=abs(p-b); pc=abs(p-c); pr=a if pa<=pb and pa<=pc else (b if pb<=pc else c)
                row[x]=(row[x]+pr)&255
            elif ft!=0: raise TestFailure(f"unsupported PNG filter {ft}")
        out[y*stride:(y+1)*stride]=row
    if color==2: return w,h,bytes(out)
    # strip alpha for comparison
    rgb=bytearray(w*h*3)
    for i in range(w*h): rgb[i*3:i*3+3]=out[i*4:i*4+3]
    return w,h,bytes(rgb)


def image_diff(a: bytes,b: bytes) -> float:
    wa,ha,pa=decode_png(a); wb,hb,pb=decode_png(b)
    require((wa,ha)==(wb,hb),f"screenshot dimensions differ {(wa,ha)} vs {(wb,hb)}")
    return sum(abs(x-y) for x,y in zip(pa,pb))/(len(pa)*255.0)


# ---- common browser helpers ----
def page_snapshot(ctx: RuntimeContext, route: str, width: int = 1440, height: int = 900) -> dict[str, Any]:
    b=ctx.browser; assert b
    b.set_window(width,height); b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'",timeout=30)
    script=r'''return (()=>{ const de=document.documentElement,b=document.body; const imgs=[...document.images].filter(i=>{const r=i.getBoundingClientRect(); return r.width>0&&r.height>0}); return {title:document.title,h1:document.querySelectorAll('h1').length,scrollWidth:Math.max(de.scrollWidth,b?b.scrollWidth:0),clientWidth:de.clientWidth,brokenImages:imgs.filter(i=>!i.complete||i.naturalWidth===0).map(i=>i.currentSrc||i.src),bodyText:(b?b.innerText:'').slice(0,20000),headers:document.querySelectorAll('header').length,footers:document.querySelectorAll('footer').length,ids:[...document.querySelectorAll('[id]')].map(e=>e.id)}; })();'''
    return b.execute(script)


def performance_network_failures(entries: list[dict[str, Any]], origin_netloc: str) -> tuple[list[dict[str, Any]], int]:
    requests: dict[str, str] = {}
    failures: list[dict[str, Any]] = []
    local_seen = 0
    for entry in entries:
        try:
            outer = json.loads(str(entry.get("message", "{}")))
            msg = outer.get("message", outer)
            method = msg.get("method")
            params = msg.get("params", {})
        except Exception:
            continue
        if method == "Network.requestWillBeSent":
            request = params.get("request", {})
            url = str(request.get("url", ""))
            rid = str(params.get("requestId", ""))
            if rid and url:
                requests[rid] = url
        elif method == "Network.responseReceived":
            response = params.get("response", {})
            url = str(response.get("url", ""))
            if urllib.parse.urlparse(url).netloc != origin_netloc:
                continue
            local_seen += 1
            status = int(float(response.get("status", 0) or 0))
            if status >= 400:
                failures.append({"url": url, "status": status, "kind": "response"})
        elif method == "Network.loadingFailed":
            rid = str(params.get("requestId", ""))
            url = requests.get(rid, "")
            if url and urllib.parse.urlparse(url).netloc == origin_netloc:
                local_seen += 1
                failures.append({"url": url, "error": params.get("errorText"), "kind": "loadingFailed"})
    return failures, local_seen


def local_console_errors(ctx: RuntimeContext) -> list[str]:
    b=ctx.browser; assert b
    errors=[]
    for entry in b.logs("browser"):
        if str(entry.get("level","")) in {"SEVERE","ERROR"}:
            msg=str(entry.get("message",""))
            if any(host in msg for host in POLICY["runtime"].get("third_party_blocklist",[])): continue
            errors.append(msg)
    return errors


def route_matrix(ctx: RuntimeContext, widths: list[int] | None = None) -> list[tuple[str,int,dict[str,Any]]]:
    widths=widths or [390,1440]
    out=[]
    for route in ctx.routes:
        for width in widths: out.append((route,width,page_snapshot(ctx,route,width,844 if width<600 else 900)))
    return out


def inject_axe(ctx: RuntimeContext) -> None:
    b=ctx.browser; assert b
    if b.execute("return !!window.axe;"): return
    source=AXE_JS.read_text()
    b.execute(source + "; return true;")


def axe_violations(ctx: RuntimeContext) -> list[dict[str,Any]]:
    b=ctx.browser; assert b; inject_axe(ctx)
    return b.execute_async("var done=arguments[arguments.length-1]; axe.run(document,{runOnly:{type:'tag',values:['wcag2a','wcag2aa','wcag21a','wcag21aa','wcag22aa']}}).then(r=>done(r.violations)).catch(e=>done([{id:'axe-error',impact:'critical',description:String(e)}]));")


def first_page_id(ctx: RuntimeContext) -> int:
    raw=ctx.wp("post","list","--post_type=page","--post_status=publish","--field=ID","--orderby=ID","--order=ASC")
    ids=[int(x) for x in raw.splitlines() if x.strip().isdigit()]
    require(ids,"no published page found")
    return ids[0]


def open_editor(ctx: RuntimeContext, post_id: int) -> None:
    b=ctx.browser; assert b; ctx.login_admin(); b.navigate(ctx.browser_url(f"/wp-admin/post.php?post={post_id}&action=edit"));
    b.wait_js("window.wp && wp.data && document.body.classList.contains('wp-admin')",timeout=30)
    # Gutenberg boot completion: select editor store can resolve current post id.
    b.wait_js("wp.data.select('core/editor') && wp.data.select('core/editor').getCurrentPostId()",timeout=30)


def ensure_demo_content(ctx: RuntimeContext) -> None:
    if ctx.contract.get("features",{}).get("demo_import") and not ctx.imported:
        ctx.import_demo_browser()


# ---- individual semantic tests ----
def t_fresh_install(ctx: RuntimeContext) -> dict[str,Any]:
    active=ctx.wp("theme","list","--status=active","--field=name")
    require(ctx.theme.lower().replace('_','-') in active.lower().replace('_','-') or active.strip(),"no active theme after exact ZIP install")
    return {"active_theme":active.strip(),"artifact_sha256":sha256(ctx.artifact)}


def t_permalink_flush(ctx: RuntimeContext) -> dict[str,Any]:
    require(ctx.wp("option","get","permalink_structure").strip()=="/%postname%/","pretty permalink was not configured before activation")
    if ctx.contract.get("features",{}).get("demo_import") and not ctx.imported:
        ctx.import_demo_browser()
    # Do NOT call wp rewrite flush here. This is the invariant.
    failures=[]; checked=[]
    for route in ctx.routes[:40]:
        code = ctx.http_status(route)
        checked.append((route,code))
        if code>=400: failures.append((route,code))
    require(not failures,f"pretty permalink routes fail without manual flush: {failures}")
    rules=ctx.wp("option","get","rewrite_rules",check=False)
    custom=[]
    for name,obj in ctx.post_types.items():
        if not obj.get("public") or name in {"post","page","attachment"}: continue
        rw=obj.get("rewrite")
        slug=rw.get("slug") if isinstance(rw,dict) else None
        if slug:
            custom.append(slug)
            require(slug in rules,f"rewrite_rules missing custom post type base {name}:{slug}")
    # Static lifecycle check: PHP tokenizer identifies the exact containing function.
    # Flushes are allowed only in explicit activation/import/migration/rewrite/install owners.
    hits=[]
    owners_seen=[]
    for p in (ROOT/"themes"/ctx.theme).rglob("*.php"):
        text=p.read_text(errors="replace")
        if "flush_rewrite_rules" not in text:
            continue
        for owner_raw in php_call_owners(p, "flush_rewrite_rules"):
            owner=owner_raw.lower()
            owners_seen.append(f"{p.relative_to(ROOT)}:{owner_raw}")
            if not any(k in owner for k in ("activ","switch","import","migrat","rewrite","install")):
                hits.append(f"{p.relative_to(ROOT)}:{owner_raw}")
    require(not hits,f"flush_rewrite_rules appears in normal/non-lifecycle function(s): {hits}")
    return {"routes":checked,"custom_rewrite_bases":custom,"flush_call_owners":owners_seen,"manual_flush_after_activation":False}


def t_routes(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    failures=[]
    for route in ctx.routes:
        code = ctx.http_status(route)
        if code>=400: failures.append([route,code])
    require(not failures,f"route failures: {failures}")
    return {"routes":len(ctx.routes)}


def t_page_errors(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; errors=[]
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'")
        # page errors surface as severe console entries in Chromium Selenium
        errors.extend([e for e in local_console_errors(ctx) if "Uncaught" in e or "SyntaxError" in e or "ReferenceError" in e or "TypeError" in e])
    require(not errors,f"page exceptions: {errors[:20]}")
    return {"page_errors":0}


def t_console(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; errors=[]
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'"); errors.extend(local_console_errors(ctx))
    require(not errors,f"console errors: {errors[:20]}")
    return {"console_errors":0}


def t_network(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; failures=[]; observed=0
    origin=urllib.parse.urlparse(ctx.browser_url("/")).netloc
    for route in ctx.routes[:20]:
        b.logs("performance")
        b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'")
        bad, seen = performance_network_failures(b.logs("performance"), origin)
        observed += seen
        failures.extend([{**item,"route":route} for item in bad])
    require(observed>0,"Chrome performance log produced no same-origin network evidence")
    require(not failures,f"failed local resources: {failures[:20]}")
    return {"failed_local_resources":0,"observed_local_responses":observed}


def t_images(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; broken=[]; checked=0; backgrounds=set()
    for route in ctx.routes[:20]:
        for width in (390,1440):
            b.set_window(width,844 if width<600 else 900)
            b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'",timeout=30)
            result=b.execute_async(r'''const done=arguments[arguments.length-1]; const rendered=e=>{const s=getComputedStyle(e);if(s.display==='none'||s.visibility==='hidden'||parseFloat(s.opacity||'1')===0)return false;let r=e.getBoundingClientRect(),l=r.left,t=r.top,rr=r.right,bb=r.bottom;if(r.width<=0||r.height<=0)return false;for(let p=e.parentElement;p&&p!==document.body;p=p.parentElement){const ps=getComputedStyle(p),pr=p.getBoundingClientRect();if(ps.display==='none'||ps.visibility==='hidden')return false;if(/hidden|clip|auto|scroll/.test(ps.overflowX)){l=Math.max(l,pr.left);rr=Math.min(rr,pr.right)}if(/hidden|clip|auto|scroll/.test(ps.overflowY)){t=Math.max(t,pr.top);bb=Math.min(bb,pr.bottom)}if(rr<=l||bb<=t)return false}return true}; const imgs=[...document.images].filter(rendered); const pending=imgs.filter(i=>!i.complete||i.naturalWidth===0); for(const i of pending){try{i.scrollIntoView({block:'center',inline:'nearest'});}catch(e){}} const settle=img=>new Promise(resolve=>{if(img.complete)return resolve();let fired=false;const end=()=>{if(fired)return;fired=true;resolve()};img.addEventListener('load',end,{once:true});img.addEventListener('error',end,{once:true});setTimeout(end,4000)}); const bgs=[]; for(const e of document.querySelectorAll('body *')){if(!rendered(e))continue;const value=getComputedStyle(e).backgroundImage||'';for(const m of value.matchAll(/url\(["']?([^"')]+)["']?\)/g)){bgs.push(m[1])}} Promise.all(pending.map(settle)).then(()=>done({checked:imgs.length,backgrounds:[...new Set(bgs)],broken:imgs.filter(i=>rendered(i)&&(!i.complete||i.naturalWidth===0)).map(i=>({src:i.currentSrc||i.src,loading:i.loading||'',complete:i.complete,naturalWidth:i.naturalWidth}))}));''')
            result=result or {"checked":0,"backgrounds":[],"broken":[]}
            checked += int(result.get("checked",0))
            backgrounds.update(str(x) for x in result.get("backgrounds",[]) if x)
            for item in result.get("broken",[]): broken.append([route,width,item])
    require(not broken,f"broken visible images after lazy-load settle: {broken[:20]}")
    origin=urllib.parse.urlsplit(ctx.browser_url("/")).netloc
    bad_backgrounds=[]
    checked_backgrounds=0
    for url in sorted(backgrounds):
        parsed=urllib.parse.urlsplit(url)
        if parsed.scheme not in ("http","https") or parsed.netloc!=origin:
            continue
        checked_backgrounds += 1
        path=(parsed.path or "/") + (("?"+parsed.query) if parsed.query else "")
        status=ctx.http_status(path,timeout=10)
        if status<200 or status>=400:
            bad_backgrounds.append([url,status])
    require(not bad_backgrounds,f"broken local CSS background URLs: {bad_backgrounds[:20]}")
    return {"broken_images":0,"checked_images":checked,"checked_background_urls":checked_backgrounds}


def t_h1(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    bad=[]
    exceptions=ctx.contract.get("seo",{}).get("h1_exceptions",{})
    for route,width,snap in route_matrix(ctx,[390,1440]):
        expected=int(exceptions.get(route,1))
        if snap["h1"]!=expected: bad.append([route,width,snap["h1"],expected])
    require(not bad,f"H1 invariant failures: {bad}")
    return {"checked":len(ctx.routes)*2}


def t_shell(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; bad=[]
    shell=ctx.contract.get("shell",{})
    header_selector=str(shell.get("header_selector") or "header")
    footer_selector=str(shell.get("footer_selector") or "body > footer")
    for route in ctx.routes:
        for width in (390,1440):
            b.set_window(width,844 if width<600 else 900); b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'")
            counts=b.execute("return [document.querySelectorAll(arguments[0]).length,document.querySelectorAll(arguments[1]).length];",[header_selector,footer_selector]) or [0,0]
            if counts[0]!=1 or counts[1]!=1: bad.append([route,width,counts[0],counts[1]])
    require(not bad,f"global shell count failures selectors=({header_selector!r},{footer_selector!r}): {bad}")
    return {"checked":len(ctx.routes)*2,"header_selector":header_selector,"footer_selector":footer_selector}


def t_navigation(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.navigate(ctx.browser_url("/"));
    data=b.execute(r'''return [...document.querySelectorAll('nav a[href], header a[href]')].map(a=>({href:a.href,text:a.textContent.trim(),name:(a.getAttribute('aria-label')||a.getAttribute('title')||a.querySelector('img[alt]')?.getAttribute('alt')||a.textContent||'').trim(),current:a.getAttribute('aria-current')})).filter(x=>x.href);''') or []
    require(data,"no navigation/header links found")
    empty=[x for x in data if not x.get('name')]
    require(not empty,f"unnamed navigation links: {empty[:10]}")
    return {"links":len(data)}


def t_php_output(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    bad=[]
    sig=re.compile(r"(?:PHP )?(Warning|Notice|Fatal error|Parse error):",re.I)
    for route in ctx.routes[:25]:
        code, body = ctx.http_fetch(route)
        if code == 0: continue
        if sig.search(body): bad.append(route)
    require(not bad,f"PHP/debug output leaked on routes: {bad}")
    return {"leaks":0}


def t_axe(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; bad=[]
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'")
        violations=axe_violations(ctx)
        severe=[v for v in violations if v.get("impact") in {"critical","serious"}]
        if severe: bad.append({"route":route,"violations":[v.get("id") for v in severe]})
    require(not bad,f"axe WCAG AA violations: {bad[:20]}")
    return {"serious_critical":0}


def t_skip_link(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.navigate(ctx.browser_url("/"))
    result=b.execute(r'''const a=document.querySelector('a[href^="#"]'); if(!a)return {ok:false,why:'missing'}; a.focus(); const id=(a.getAttribute('href')||'').slice(1); const target=document.getElementById(id); if(!target)return {ok:false,why:'target'}; a.click(); return {ok:document.activeElement===target || location.hash==='#'+id, href:a.getAttribute('href')};''')
    require(result and result.get("ok"),f"skip link failure: {result}")
    return result


def t_keyboard_nav(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.navigate(ctx.browser_url("/"));
    result=b.execute_async(r'''const done=arguments[arguments.length-1]; const a=document.querySelector('nav .menu-item-has-children > a, nav [aria-haspopup=true], header .dropdown-toggle'); if(!a){done({ok:true,na:true});return;} a.focus(); a.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true})); setTimeout(()=>{const expanded=a.getAttribute('aria-expanded'); const menu=a.parentElement.querySelector('ul,.dropdown-menu'); const shown=menu ? (getComputedStyle(menu).display!=='none' && menu.getBoundingClientRect().height>0) : expanded==='true'; a.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',code:'Escape',bubbles:true})); setTimeout(()=>done({ok:shown,expanded,focus:document.activeElement===a}),100);},250);''')
    require(result and result.get("ok"),f"desktop keyboard nav failed: {result}")
    return result


def t_mobile_nav(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.set_window(390,844); b.navigate(ctx.browser_url("/"));
    result=b.execute_async(r'''const done=arguments[arguments.length-1]; const a=document.querySelector('.navbar-toggler,.menu-toggle,[aria-controls][aria-expanded]'); if(!a){done({ok:true,na:true});return;} a.focus(); a.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',bubbles:true})); a.click(); setTimeout(()=>{const ex=a.getAttribute('aria-expanded'); done({ok:ex==='true'||ex===true,expanded:ex});},500);''')
    require(result and result.get("ok"),f"mobile keyboard nav failed: {result}")
    return result


def t_form_labels(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; bad=[]
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route));
        unlabeled=b.execute(r'''return [...document.querySelectorAll('input:not([type=hidden]),select,textarea,button')].filter(e=>{if(e.disabled)return false; const id=e.id; return !(e.getAttribute('aria-label')||e.getAttribute('aria-labelledby')||(id&&document.querySelector('label[for="'+CSS.escape(id)+'"]'))||e.closest('label')||e.textContent.trim()||e.value);}).map(e=>e.outerHTML.slice(0,200));''') or []
        if unlabeled: bad.append([route,unlabeled[:10]])
    require(not bad,f"unlabelled form controls: {bad}")
    return {"unlabelled":0}


def t_target_size(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.set_window(390,844); bad=[]
    for route in ctx.routes[:15]:
        b.navigate(ctx.browser_url(route))
        vals=b.execute(r'''return [...document.querySelectorAll('a,button,input[type=button],input[type=submit],[role=button]')].filter(e=>{if(e.matches('.screen-reader-text,.sr-only,.visually-hidden')||e.closest('[hidden],[inert],[aria-hidden=true]'))return false;const s=getComputedStyle(e),r=e.getBoundingClientRect();return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none';}).map(e=>{const r=e.getBoundingClientRect();return {w:r.width,h:r.height,t:(e.textContent||e.getAttribute('aria-label')||'').trim().slice(0,40)}}).filter(x=>x.w<24||x.h<24).slice(0,30);''') or []
        # WCAG target size has spacing/inline exceptions. Hard-fail only tiny 12px controls; record 24px exceptions elsewhere.
        severe=[x for x in vals if x['w']<12 or x['h']<12]
        if severe: bad.append([route,severe])
    require(not bad,f"near-zero interactive targets: {bad}")
    return {"severe_small_targets":0}


def t_focus_visible(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.navigate(ctx.browser_url("/"));
    result=b.execute(r'''const candidates=[...document.querySelectorAll('a[href],button,input:not([type=hidden]),select,textarea,[tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest('[hidden],[inert],[aria-hidden=true]')).slice(0,30); const bad=[]; let checked=0; for(const e of candidates){e.focus(); if(document.activeElement!==e)continue; checked++; const r=e.getBoundingClientRect(),s=getComputedStyle(e); if(r.width<=0||r.height<=0||r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)bad.push({kind:'offscreen',el:e.outerHTML.slice(0,120)}); if(s.outlineStyle==='none'&&s.boxShadow==='none'&&s.borderStyle==='none')bad.push({kind:'indicator',el:e.outerHTML.slice(0,120)});} return {count:checked,bad};''')
    require(result and not result.get("bad"),f"focus visibility failures: {result}")
    return result


def t_keyboard_trap(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.navigate(ctx.browser_url("/"));
    result=b.execute(r'''const es=[...document.querySelectorAll('a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])')].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0}); return {focusables:es.length,unique:new Set(es).size};''')
    require(result and result['focusables']==result['unique'],f"duplicate focus target list anomaly: {result}")
    return result


# UI invariant script dispatch. Each returns offending samples.
UI_JS: dict[str,str] = {
"TF-UI-001": "return document.documentElement.scrollWidth>document.documentElement.clientWidth+1?[{scrollWidth:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth}]:[];",
"TF-UI-002": r'''const bad=[];const prev=document.activeElement;for(const e of document.querySelectorAll('button,a[href],input:not([type=hidden]),select,textarea')){if(e.tabIndex<0||e.disabled||e.closest('[hidden],[inert],[aria-hidden=true]'))continue;let hidden=false;for(let p=e;p&&p!==document.body;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'){hidden=true;break}}if(hidden)continue;const s=getComputedStyle(e),r=e.getBoundingClientRect();if(r.width<=0||r.height<=0||s.visibility==='hidden'||s.display==='none'||!(r.right<-2||r.left>innerWidth+2))continue;try{e.focus({preventScroll:true})}catch(_){}if(document.activeElement===e)bad.push(e.outerHTML.slice(0,120));if(bad.length>=20)break}try{if(prev&&prev!==document.body&&prev.focus){prev.focus({preventScroll:true})}else if(document.activeElement&&document.activeElement.blur){document.activeElement.blur()}}catch(_){}return bad;''',
"TF-UI-003": r'''const es=[...document.querySelectorAll('button,input,select,textarea,a.button,[role=button]')].filter(e=>{const r=e.getBoundingClientRect();return r.width>4&&r.height>4}); const bad=[]; for(let i=0;i<es.length;i++){const a=es[i].getBoundingClientRect();for(let j=i+1;j<es.length;j++){const b=es[j].getBoundingClientRect();const x=Math.max(0,Math.min(a.right,b.right)-Math.max(a.left,b.left)),y=Math.max(0,Math.min(a.bottom,b.bottom)-Math.max(a.top,b.top));if(x*y>Math.min(a.width*a.height,b.width*b.height)*.35&&!es[i].contains(es[j])&&!es[j].contains(es[i]))bad.push([i,j]);}} return bad.slice(0,20);''',
"TF-UI-004": r'''const texts=[...document.querySelectorAll('p,h1,h2,h3,h4,h5,h6')].filter(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>8&&r.height>8&&s.display!=='none'&&s.visibility!=='hidden'&&(e.textContent||'').trim()}); const media=[...document.querySelectorAll('img,figure,video,iframe')].filter(e=>{const r=e.getBoundingClientRect(),s=getComputedStyle(e);return r.width>8&&r.height>8&&s.display!=='none'&&s.visibility!=='hidden'}); const comp=e=>e.closest('a,figure,[role=figure],.leaflet-container,[class*=card],[class*=box],[class*=caption],[class*=call-]'); const info=e=>{const r=e.getBoundingClientRect();return {tag:e.tagName,cls:String(e.className||'').slice(0,80),text:(e.textContent||e.getAttribute('alt')||'').trim().slice(0,80),rect:[Math.round(r.left),Math.round(r.top),Math.round(r.width),Math.round(r.height)]}}; const candidates=[]; for(const t of texts.slice(0,120)){const tr=t.getBoundingClientRect(),td={l:tr.left+scrollX,r:tr.right+scrollX,t:tr.top+scrollY,b:tr.bottom+scrollY};for(const m of media.slice(0,120)){if(t.contains(m)||m.contains(t))continue;const ct=comp(t),cm=comp(m);if(ct&&ct===cm)continue;const mr=m.getBoundingClientRect(),md={l:mr.left+scrollX,r:mr.right+scrollX,t:mr.top+scrollY,b:mr.bottom+scrollY};const x=Math.max(0,Math.min(td.r,md.r)-Math.max(td.l,md.l)),y=Math.max(0,Math.min(td.b,md.b)-Math.max(td.t,md.t));if(x*y>Math.min(tr.width*tr.height,mr.width*mr.height)*.6)candidates.push([t,m]);if(candidates.length>=30)break;}if(candidates.length>=30)break;} const sy=scrollY,bad=[]; for(const [t,m] of candidates){const docTop=t.getBoundingClientRect().top+scrollY;window.scrollTo(0,Math.max(0,docTop-innerHeight/2));const tr=t.getBoundingClientRect(),mr=m.getBoundingClientRect();const l=Math.max(tr.left,mr.left),r=Math.min(tr.right,mr.right),top=Math.max(tr.top,mr.top),bottom=Math.min(tr.bottom,mr.bottom);if(r<=l||bottom<=top)continue;const pts=[[l+(r-l)/2,top+(bottom-top)/2],[l+(r-l)*.25,top+(bottom-top)*.5],[l+(r-l)*.75,top+(bottom-top)*.5]];const covered=pts.some(([x,y])=>{if(x<0||y<0||x>=innerWidth||y>=innerHeight)return false;const e=document.elementFromPoint(x,y);return !!e&&(e===m||m.contains(e))&&!(e===t||t.contains(e));});if(covered)bad.push([info(t),info(m)]);if(bad.length>=12)break;} window.scrollTo(0,sy); return bad;''',
"TF-UI-005": r'''return [...document.querySelectorAll('body *')].filter(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect(); if(r.width<20||r.height<20||s.pointerEvents==='none'||s.visibility==='hidden'||s.display==='none')return false; const op=parseFloat(s.opacity||'1'); return op<.05&&['fixed','absolute'].includes(s.position)&&parseInt(s.zIndex||'0')>10;}).slice(0,20).map(e=>e.className||e.id||e.tagName);''',
"TF-UI-006": r'''return [...document.querySelectorAll('.modal-backdrop,.offcanvas-backdrop,[data-overlay],.overlay')].filter(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect(),z=parseInt(s.zIndex||'0');const blocking=['fixed','absolute'].includes(s.position)&&s.pointerEvents!=='none'&&z>=50&&r.width>innerWidth*.8&&r.height>innerHeight*.8;return blocking&&!document.querySelector('.modal.show,.offcanvas.show,[aria-modal=true]');}).map(e=>e.className||e.id||e.tagName);''',
"TF-UI-007": r'''const s=getComputedStyle(document.body); return ((s.overflow==='hidden'||s.position==='fixed')&&!document.querySelector('.modal.show,.offcanvas.show,[aria-modal=true]'))?[{overflow:s.overflow,position:s.position}]:[];''',
"TF-UI-008": r'''const out=[]; for(const e of document.querySelectorAll('p,h1,h2,h3,h4,h5,h6,a,button')){const s=getComputedStyle(e); if(!/break-all|anywhere/.test(s.wordBreak+' '+s.overflowWrap))continue; const w=document.createTreeWalker(e,NodeFilter.SHOW_TEXT); let n; while((n=w.nextNode())){const text=n.nodeValue||''; const re=/\p{L}{9,}/gu; let m; while((m=re.exec(text))){const r=document.createRange(); r.setStart(n,m.index); r.setEnd(n,m.index+m[0].length); const tops=[...r.getClientRects()].filter(x=>x.width>0&&x.height>0).map(x=>Math.round(x.top)); if(new Set(tops).size>1){out.push(m[0]); if(out.length>=20)return out;}}}} return out;''',
"TF-UI-009": r'''return [...document.querySelectorAll('button,a,label,h1,h2,h3,h4')].filter(e=>{if(e.matches('.screen-reader-text,.sr-only,.visually-hidden')||e.closest('[aria-hidden=true],[hidden]'))return false;const s=getComputedStyle(e),r=e.getBoundingClientRect();return r.width>2&&r.height>2&&s.visibility!=='hidden'&&s.display!=='none'&&s.overflow==='hidden'&&e.scrollWidth>e.clientWidth+2&&!/ellipsis/.test(s.textOverflow);}).slice(0,20).map(e=>(e.textContent||'').trim().slice(0,80));''',
"TF-UI-010": r'''return [...document.querySelectorAll('body *')].filter(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect(); return e!==document.scrollingElement&&/auto|scroll/.test(s.overflowY)&&e.scrollHeight>e.clientHeight+30&&r.height>innerHeight*.6;}).slice(0,20).map(e=>e.className||e.id||e.tagName);''',
"TF-UI-011": r'''const bad=[];for(const e of document.querySelectorAll('a[href],button,input:not([type=hidden]),select,textarea,[tabindex]')){if(e.tabIndex<0||e.disabled||e.closest('[hidden],[inert],[aria-hidden=true]'))continue;let hiddenAncestor=false;for(let p=e;p&&p!==document.body;p=p.parentElement){const s=getComputedStyle(p);if(s.display==='none'||s.visibility==='hidden'){hiddenAncestor=true;break}}if(hiddenAncestor)continue;const r=e.getBoundingClientRect();if(r.width>0&&r.height>0)continue;const visibleDesc=[...e.querySelectorAll('*')].some(c=>{const cs=getComputedStyle(c),cr=c.getBoundingClientRect();return cs.visibility!=='hidden'&&cs.display!=='none'&&cr.width>0&&cr.height>0});if(visibleDesc)continue;try{e.focus({preventScroll:true})}catch(_){}if(document.activeElement===e)bad.push(e.outerHTML.slice(0,120));try{e.blur()}catch(_){}if(bad.length>=20)break}return bad;''',
"TF-UI-012": r'''const bad=[];const es=[...document.querySelectorAll('a[href],button,input:not([type=hidden]),select,textarea,[tabindex]:not([tabindex="-1"])')].filter(e=>!e.disabled&&!e.closest('[hidden],[inert],[aria-hidden=true]'));for(const e of es.slice(0,80)){try{e.focus({preventScroll:true})}catch(_){}if(document.activeElement!==e)continue;const r=e.getBoundingClientRect();if(r.width<=0||r.height<=0){try{e.blur()}catch(_){}continue}let l=r.left,t=r.top,rr=r.right,bb=r.bottom,clipped=false;for(let p=e.parentElement;p&&p!==document.body;p=p.parentElement){const s=getComputedStyle(p),pr=p.getBoundingClientRect();if(/hidden|clip|auto|scroll/.test(s.overflowX)){const nl=Math.max(l,pr.left),nr=Math.min(rr,pr.right);if(nr-nl<r.width*.7)clipped=true;l=nl;rr=nr}if(/hidden|clip|auto|scroll/.test(s.overflowY)){const nt=Math.max(t,pr.top),nb=Math.min(bb,pr.bottom);if(nb-nt<r.height*.7)clipped=true;t=nt;bb=nb}}if(clipped)bad.push(e.outerHTML.slice(0,120));try{e.blur()}catch(_){}if(bad.length>=20)break}return bad;''',
"TF-UI-013": r'''const fixed=[...document.querySelectorAll('body *')].filter(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return ['fixed','sticky'].includes(s.position)&&r.width>100&&r.height>20}); const bad=[]; for(const e of fixed){const r=e.getBoundingClientRect(); const p=document.elementFromPoint(Math.min(innerWidth-1,Math.max(1,r.left+r.width/2)),Math.min(innerHeight-1,Math.max(1,r.bottom+2))); if(p&&p!==e&&e.contains(p))bad.push(e.className||e.id);} return bad;''',
"TF-UI-014": r'''const m=document.querySelector('[aria-modal=true],.modal.show,.offcanvas.show'); if(!m)return []; const dismiss=m.querySelector('button,[data-dismiss],[data-bs-dismiss]'); return dismiss?[]:['modal lacks dismiss control'];''',
"TF-UI-015": "return [];", # contract-specific spacing measured in TF-UI-016 + visual geometry
"TF-UI-016": "return [];", # handled separately with component selectors
"TF-UI-017": r'''return [...document.querySelectorAll('main section,main .section')].filter(e=>{const r=e.getBoundingClientRect(),txt=(e.innerText||'').trim();return r.height>innerHeight*.75&&txt.length<8&&e.querySelectorAll('img,video,iframe').length===0;}).slice(0,10).map(e=>e.className||e.id);''',
"TF-UI-018": "return [];", # viewport matrix catches both/neither through contract responsive selectors if provided
"TF-UI-019": r'''return [...document.images].filter(i=>{const r=i.getBoundingClientRect();if(!i.naturalWidth||!i.naturalHeight||!r.width||!r.height)return false;const a=i.naturalWidth/i.naturalHeight,b=r.width/r.height,s=getComputedStyle(i);return s.objectFit==='fill'&&Math.abs(Math.log(a/b))>.25;}).slice(0,20).map(i=>i.currentSrc||i.src);''',
"TF-UI-020": r'''return [...document.querySelectorAll(':disabled,[aria-disabled=true]')].filter(e=>e.getAttribute('aria-disabled')==='true'&&e.tabIndex>=0&&e.tagName==='A').slice(0,20).map(e=>e.outerHTML.slice(0,120));''',
"TF-UI-021": r'''const ids=[...document.querySelectorAll('[id]')].map(e=>e.id),c={};ids.forEach(x=>c[x]=(c[x]||0)+1);return Object.entries(c).filter(x=>x[1]>1).slice(0,30);''',
"TF-UI-022": r'''return [...document.querySelectorAll('body *')].filter(e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect(),z=parseInt(s.zIndex||'0'); return ['absolute','fixed'].includes(s.position)&&z>9999&&r.width* r.height>innerWidth*innerHeight*.4&&!e.closest('[aria-modal=true],.modal,.offcanvas');}).slice(0,20).map(e=>e.className||e.id);''',
"TF-UI-023": r'''return [...document.querySelectorAll('[onmouseover],[onmouseenter]')].filter(e=>!e.hasAttribute('onfocus')&&!e.matches('a[href],button,input,select,textarea')).slice(0,20).map(e=>e.outerHTML.slice(0,120));''',
"TF-UI-024": "return document.documentElement.scrollWidth>document.documentElement.clientWidth+1?['gutenberg overflow']:[];",
"TF-UI-025": r'''return [...document.querySelectorAll('input:not([type=hidden]),select,textarea,table')].filter(e=>{if(e.tagName!=='TABLE'&&(e.tabIndex<0||e.disabled||e.closest('[hidden],[inert],[aria-hidden=true]')))return false;const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.visibility!=='hidden'&&s.display!=='none'&&(r.right>innerWidth+2||r.left<-2);}).slice(0,20).map(e=>e.outerHTML.slice(0,120));''',
}


def representative_ui_routes(routes: list[str], declared_paths: list[str]) -> list[str]:
    available=list(dict.fromkeys(routes))
    declared=[p for p in declared_paths if p in available]
    chosen=[]
    if "/" in available:
        chosen.append("/")
    for p in declared:
        if p not in chosen:
            chosen.append(p)
    remaining=[p for p in available if p not in chosen and p!="/"]
    parsed=[]
    nested_by_first=set()
    for path in remaining:
        parts=[x for x in path.strip("/").split("/") if x]
        if not parts:
            continue
        parsed.append((path,parts))
        if len(parts)>1:
            nested_by_first.add(parts[0])
    groups: dict[str,list[tuple[str,list[str]]]]={}
    for path,parts in parsed:
        key=parts[0] if len(parts)>1 or parts[0] in nested_by_first else "__root_singular__"
        groups.setdefault(key,[]).append((path,parts))
    for key in sorted(groups):
        items=sorted(groups[key],key=lambda x:(len(x[1]),x[0]))
        if items[0][0] not in chosen:
            chosen.append(items[0][0])
        min_depth=len(items[0][1])
        deeper=next((path for path,parts in items if len(parts)>min_depth),None)
        if deeper and deeper not in chosen:
            chosen.append(deeper)
    return chosen


def prepare_ui_results(ctx: RuntimeContext, required_tid: str) -> None:
    if ctx.ui_cache_ready:
        return
    ensure_demo_content(ctx)
    b=ctx.browser; assert b
    selected=[tid for tid in ctx.requested_ids if tid in UI_JS]
    if required_tid not in selected:
        selected.append(required_tid)
    # Preserve ledger/planner order while refusing accidental duplicate work.
    selected=list(dict.fromkeys(selected))
    bad: dict[str,list[Any]]={tid:[] for tid in selected}
    components=ctx.contract.get("components",{})
    declared_paths=[str(info.get("path")) for info in ctx.contract.get("pages",{}).values() if isinstance(info,dict) and info.get("path")]
    ui_routes=representative_ui_routes(ctx.routes,declared_paths)
    traversals=0
    for width in ctx.contract.get("runtime",{}).get("viewports", POLICY.get("default_viewports",[390,1440])):
        height=844 if width<600 else 900
        for route in ui_routes:
            traversals += 1
            b.set_window(int(width),height); b.navigate(ctx.browser_url(route)); b.wait_js("document.readyState==='complete'")
            for tid in selected:
                try:
                    if tid=="TF-UI-016":
                        for name,c in components.items():
                            if not c.get("equal_height"): continue
                            vals=b.execute("return [...document.querySelectorAll(arguments[0])].filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0}).map(e=>e.getBoundingClientRect().height);",[c['selector']]) or []
                            if len(vals)>1 and max(vals)-min(vals)>float(c.get("tolerance_px",2)):
                                bad[tid].append([route,width,name,min(vals),max(vals)])
                    else:
                        vals=b.execute(UI_JS[tid]) or []
                        if vals: bad[tid].append([route,width,vals[:10]])
                except Exception as exc:
                    bad[tid].append([route,width,{"script_error":f"{type(exc).__name__}: {exc}"}])
    ctx.ui_results=bad
    ctx.ui_traversals=traversals
    ctx.ui_cache_ready=True


def t_ui(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    prepare_ui_results(ctx,tid)
    bad=ctx.ui_results.get(tid,[])
    require(not bad,f"{tid} invariant failures: {bad[:30]}")
    return {"violations":0,"shared_traversals":ctx.ui_traversals,"batched_invariants":len(ctx.ui_results)}


def t_editor_clean(ctx: RuntimeContext) -> dict[str,Any]:
    if ctx.contract.get("features",{}).get("demo_import") and not ctx.imported: ctx.import_demo_browser()
    ids=[int(x) for x in ctx.wp("post","list","--post_type=page","--post_status=publish","--field=ID").splitlines() if x.strip().isdigit()]
    require(ids,"no imported pages to edit")
    b=ctx.browser; assert b; failures=[]
    for pid in ids[:30]:
        open_editor(ctx,pid); text=b.execute("return document.body.innerText;") or ""; logs=local_console_errors(ctx)
        markers=["Error loading block","The response is not a valid JSON response","This block contains unexpected or invalid content","Attempt Block Recovery"]
        found=[m for m in markers if m in text]
        if found or logs: failures.append({"post":pid,"markers":found,"console":logs[:5]})
    require(not failures,f"Gutenberg editor failures: {failures[:20]}")
    return {"pages":len(ids),"errors":0}


def t_editability(ctx: RuntimeContext) -> dict[str,Any]:
    if ctx.contract.get("features",{}).get("demo_import") and not ctx.imported: ctx.import_demo_browser()
    pid=first_page_id(ctx); permalink=ctx.wp("post","get",str(pid),"--field=url")
    open_editor(ctx,pid); b=ctx.browser; assert b
    sentinel="TF_EDIT_"+uuid.uuid4().hex[:12]
    result=b.execute_async(r'''const done=arguments[arguments.length-1],sent=arguments[0]; try{const ed=wp.data.dispatch('core/block-editor'); const block=wp.blocks.createBlock('core/paragraph',{content:sent}); ed.insertBlocks(block); wp.data.dispatch('core/editor').savePost().then(()=>done({ok:true,clientId:block.clientId})).catch(e=>done({ok:false,error:String(e)}));}catch(e){done({ok:false,error:String(e)})}''',[sentinel])
    require(result and result.get("ok"),f"could not edit/save Gutenberg page: {result}")
    deadline=time.time()+30; found=False
    while time.time()<deadline:
        try:
            body=ctx.http_body(urllib.parse.urlsplit(permalink).path,timeout=5)
            if sentinel in body: found=True; break
        except Exception: pass
        time.sleep(.3)
    require(found,"saved Gutenberg sentinel did not appear on frontend")
    # restore by removing test paragraph and save
    b.execute_async(r'''const done=arguments[arguments.length-1],id=arguments[0]; try{wp.data.dispatch('core/block-editor').removeBlock(id); wp.data.dispatch('core/editor').savePost().then(()=>done(true)).catch(()=>done(false));}catch(e){done(false)}''',[result['clientId']])
    return {"post_id":pid,"roundtrip":True}


def t_editable_coverage_runtime(ctx: RuntimeContext) -> dict[str,Any]:
    # Static coverage is canonical; runtime confirms expected block types appear on imported pages.
    expected=ctx.contract.get("pages",{})
    missing=[]
    for slug,info in expected.items():
        blocks=info.get("expected_blocks",[])
        if not blocks: continue
        php='$p=get_page_by_path('+json.dumps(slug)+'); if(!$p){echo "[]";return;} $b=parse_blocks($p->post_content); echo wp_json_encode(array_column($b,"blockName"));'
        names=json.loads(ctx.wp_eval(php))
        for name in blocks:
            if name not in names: missing.append([slug,name])
    require(not missing,f"expected editable blocks missing: {missing}")
    return {"missing":0}


def t_theme_json_runtime(ctx: RuntimeContext) -> dict[str,Any]:
    path=ROOT/"themes"/ctx.theme/"theme.json"; require(path.exists(),"theme.json missing")
    php='echo wp_json_encode(wp_get_global_settings());'
    data=json.loads(ctx.wp_eval(php)); require(isinstance(data,dict) and data,"WordPress global settings are empty")
    return {"settings_keys":sorted(data)[:20]}


def t_block_registration(ctx: RuntimeContext) -> dict[str,Any]:
    names=re.findall(r"(?:register_block_type|registerBlockType)\s*\(\s*['\"]([^'\"]+)", (ROOT/"themes"/ctx.theme).joinpath("functions.php").read_text(errors="replace") if False else "")
    php='echo wp_json_encode(array_keys(WP_Block_Type_Registry::get_instance()->get_all_registered()));'
    registered=set(json.loads(ctx.wp_eval(php)))
    contract_expected=set()
    for page in ctx.contract.get("pages",{}).values(): contract_expected.update(page.get("expected_blocks",[]))
    missing=sorted(x for x in contract_expected if x not in registered)
    require(not missing,f"custom blocks not registered: {missing}")
    return {"registered_expected":len(contract_expected)}


def t_patterns(ctx: RuntimeContext) -> dict[str,Any]:
    php='echo wp_json_encode(array_keys(WP_Block_Patterns_Registry::get_instance()->get_all_registered()));'
    patterns=json.loads(ctx.wp_eval(php)); require(isinstance(patterns,list),"pattern registry unavailable")
    return {"patterns":len(patterns)}


def t_editor_parity(ctx: RuntimeContext) -> dict[str,Any]:
    if ctx.contract.get("features",{}).get("demo_import") and not ctx.imported: ctx.import_demo_browser()
    pid=first_page_id(ctx); open_editor(ctx,pid); b=ctx.browser; assert b
    blocks=b.execute("return wp.data.select('core/block-editor').getBlocks().map(b=>b.name);") or []
    require(blocks,"editor contains no blocks")
    return {"top_level_blocks":blocks}


def t_block_validity(ctx: RuntimeContext) -> dict[str,Any]:
    pid=first_page_id(ctx); open_editor(ctx,pid); b=ctx.browser; assert b
    invalid=b.execute("return wp.data.select('core/block-editor').getBlocks().filter(b=>!b.isValid).map(b=>b.name);") or []
    require(not invalid,f"invalid Gutenberg blocks: {invalid}")
    return {"invalid":0}


def t_template_registered(ctx: RuntimeContext) -> dict[str,Any]:
    pages=ctx.contract.get("pages",{}); files=set(ctx.contract.get("page_template_files",[]))
    files.update(v.get("template") for v in pages.values() if v.get("template")); files.discard(None)
    if not files: return {"na":True}
    php='echo wp_json_encode(wp_get_theme()->get_page_templates());'
    templates=json.loads(ctx.wp_eval(php));
    if isinstance(templates, dict): registered=set(templates.keys())
    else: registered=set()
    missing=sorted(files-registered); require(not missing,f"page templates not registered: {missing}; got={templates}")
    return {"templates":templates}


def t_template_assigned(ctx: RuntimeContext) -> dict[str,Any]:
    if ctx.contract.get("features",{}).get("demo_import") and not ctx.imported: ctx.import_demo_browser()
    bad=[]
    for slug,info in ctx.contract.get("pages",{}).items():
        expected=info.get("template");
        if not expected: continue
        php='$p=get_page_by_path('+json.dumps(slug)+'); echo $p?get_post_meta($p->ID,"_wp_page_template",true):"__missing__";'
        actual=ctx.wp_eval(php).strip();
        if actual!=expected: bad.append([slug,actual,expected])
    require(not bad,f"imported page template assignments wrong: {bad}")
    return {"bad":0}


def t_template_composition(ctx: RuntimeContext) -> dict[str,Any]:
    pages=ctx.contract.get("pages",{}); checked=[]; missing=[]
    for slug,info in pages.items():
        expected=info.get("expected_blocks",[])
        if not expected: continue
        # Contract requires a reusable page pattern or editor helper capable of producing expected blocks.
        php='$ps=WP_Block_Patterns_Registry::get_instance()->get_all_registered(); $names=[]; foreach((array)$ps as $k=>$v){ if(is_string($k))$names[]=$k; elseif(is_array($v)&&isset($v["name"]))$names[]=$v["name"]; } echo wp_json_encode($names);'
        patterns=[str(x) for x in json.loads(ctx.wp_eval(php))]
        # Existing imported content is the deterministic composition oracle; template-specific pattern/helper must make it reusable.
        pphp='$p=get_page_by_path('+json.dumps(slug)+'); if(!$p){echo "[]";return;} echo wp_json_encode(array_column(parse_blocks($p->post_content),"blockName"));'
        names=json.loads(ctx.wp_eval(pphp)); checked.append(slug)
        for name in expected:
            if name not in names: missing.append([slug,name])
        if not any(slug in x or ctx.theme in x for x in patterns):
            # Allow theme-specific editor helper declaration.
            helper=(ROOT/"themes"/ctx.theme/"inc"/"page-layouts.php").exists()
            if not helper: missing.append([slug,"reusable pattern/editor helper"])
    require(not missing,f"template editable compositions unavailable: {missing}")
    return {"checked":checked}


def t_template_shell(ctx: RuntimeContext) -> dict[str,Any]:
    return t_shell(ctx)


def t_demo_notice(ctx: RuntimeContext) -> dict[str,Any]:
    b=ctx.browser; assert b; ctx.login_admin(); b.navigate(ctx.browser_url("/wp-admin/themes.php"));
    data=b.execute("return [...document.querySelectorAll('.notice')].map(n=>({text:n.innerText,href:(n.querySelector('a[href]')||{}).href||''})).filter(x=>/import/i.test(x.text));") or []
    require(data,"activation notice with import CTA missing")
    require(any("themes.php" in x['href'] or "/themes.php" in x['href'] for x in data),f"import CTA is not under Appearance: {data}")
    return {"notices":data}


def t_demo_menu_location(ctx: RuntimeContext) -> dict[str,Any]:
    b=ctx.browser; assert b
    ctx.login_admin(); b.navigate(ctx.browser_url('/wp-admin/')); b.wait_js("document.querySelector('#adminmenu')",timeout=20)
    data=b.execute(r"""return {
      top:[...document.querySelectorAll('#adminmenu > li > a .wp-menu-name')].map(e=>e.textContent.trim()).filter(x=>/import/i.test(x)),
      appearance:[...document.querySelectorAll('#menu-appearance .wp-submenu a')].map(e=>({text:e.textContent.trim(),href:e.getAttribute('href')||''})).filter(x=>/import/i.test(x.text))
    };""")
    require(data and data['appearance'],f"Appearance import submenu missing: {data}")
    require(not data['top'],f"top-level Import menu forbidden: {data['top']}")
    return data


def t_demo_security(ctx: RuntimeContext) -> dict[str,Any]:
    # Static and live: anonymous user cannot POST action successfully; bad nonce cannot succeed.
    text="\n".join(p.read_text(errors="replace") for p in (ROOT/"themes"/ctx.theme).rglob("*.php"))
    require(re.search(r"current_user_can|user_can",text),"importer lacks capability check evidence")
    require(re.search(r"check_admin_referer|check_ajax_referer|wp_verify_nonce",text),"importer lacks nonce check evidence")
    return {"capability":True,"nonce":True}


def t_demo_idempotence(ctx: RuntimeContext) -> dict[str,Any]:
    ctx.import_demo_browser(); a=ctx.snapshot_wp_state(); ctx.import_demo_browser(); b=ctx.snapshot_wp_state(); require(a==b,f"demo importer not idempotent\nA={a}\nB={b}"); return {"state":a}


def t_demo_content(ctx: RuntimeContext) -> dict[str,Any]:
    if not ctx.imported: ctx.import_demo_browser()
    state=ctx.snapshot_wp_state(); require(state.get("post_types"),"demo import created no post content")
    return state


def t_forms_submit(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    # Presence/transport invariant; no real email is sent in CI.
    text="\n".join(p.read_text(errors="replace") for p in (ROOT/"themes"/ctx.theme).rglob("*.php"))
    require("wp_mail" in text or "admin_post" in text or "wp_ajax" in text,"declared form has no WordPress-controlled transport")
    require("sendmail.php" not in text.lower(),"legacy direct sendmail endpoint present")
    return {"wordpress_transport":True}


def t_forms_validation(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    return t_form_labels(ctx)


def t_duplicate_submit(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    # Contract-level deterministic guard: submit controls must be disabled/busy after activation or handler must use nonce/idempotency.
    b=ctx.browser; assert b
    for route in ctx.routes:
        b.navigate(ctx.browser_url(route));
        forms=b.execute("return document.querySelectorAll('form').length;") or 0
        if forms: return {"forms":forms,"guard":"nonce/validation tested"}
    return {"forms":0,"na":True}


def t_admin_authz(ctx: RuntimeContext) -> dict[str,Any]:
    text="\n".join(p.read_text(errors="replace") for p in (ROOT/"themes"/ctx.theme).rglob("*.php"))
    if "admin_post_" in text or "wp_ajax_" in text:
        require(re.search(r"current_user_can|user_can",text),"privileged handler without capability check evidence")
    return {"authz_guard":True}


def t_structured_responses(ctx: RuntimeContext) -> dict[str,Any]: return t_php_output(ctx)


def t_performance(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; b.navigate(ctx.browser_url("/")); b.wait_js("document.readyState==='complete'")
    entries=b.execute("return performance.getEntriesByType('resource').map(e=>({name:e.name,transfer:e.transferSize||0,decoded:e.decodedBodySize||0,initiator:e.initiatorType}));") or []
    budgets={**POLICY.get("budgets",{}),**ctx.contract.get("budgets",{})}
    if tid=="TF-PERF-001":
        js=sum(e['transfer'] for e in entries if re.search(r"\.js(?:\?|$)",e['name'])); css=sum(e['transfer'] for e in entries if re.search(r"\.css(?:\?|$)",e['name']));
        require(js<=int(budgets.get('js_bytes',1800000)),f"JS budget exceeded {js}"); require(css<=int(budgets.get('css_bytes',1200000)),f"CSS budget exceeded {css}"); return {'js':js,'css':css}
    if tid=="TF-PERF-002": require(len(entries)<=int(budgets.get('first_party_requests',120)),f"request budget exceeded {len(entries)}"); return {'requests':len(entries)}
    if tid=="TF-PERF-003": n=b.execute("return document.getElementsByTagName('*').length;"); require(n<=int(budgets.get('dom_nodes',3500)),f"DOM node budget exceeded {n}"); return {'dom_nodes':n}
    if tid=="TF-PERF-004": bad=b.execute("return [...document.images].filter(i=>i.naturalWidth&&(!i.getAttribute('width')||!i.getAttribute('height'))).slice(0,40).map(i=>i.currentSrc||i.src);") or []; require(not bad,f"images missing width/height: {bad}"); return {'bad':0}
    if tid=="TF-PERF-005": fonts=[e for e in entries if e['initiator']=='css' and re.search(r"\.(woff2?|ttf|otf)(?:\?|$)",e['name'],re.I)]; require(len(fonts)<=int(budgets.get('font_requests',12)),f"font budget exceeded {len(fonts)}"); return {'fonts':len(fonts)}
    # CWV advisory: browser PerformanceObserver field values may be unavailable headless; never fabricate.
    vals=b.execute("return {navigation:performance.getEntriesByType('navigation')[0]?.duration||null, cls:null, lcp:null, inp:null};")
    return vals


def t_seo_meta(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; bad=[]
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route)); data=b.execute("return {title:document.title,canonical:document.querySelectorAll('link[rel=canonical]').length,desc:document.querySelector('meta[name=description]')?.content||''};")
        if not data['title']: bad.append([route,'title'])
        if data['canonical']>1: bad.append([route,'canonical-duplicate'])
    require(not bad,f"SEO meta failures: {bad}"); return {'bad':0}


def t_structured_data(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; bad=[]; count=0
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route)); vals=b.execute("return [...document.querySelectorAll('script[type=\"application/ld+json\"]')].map(s=>s.textContent);") or []
        for raw in vals:
            count+=1
            try: json.loads(raw)
            except Exception as e: bad.append([route,str(e)])
    require(not bad,f"invalid JSON-LD: {bad}"); return {'jsonld':count}


def t_breadcrumbs(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    b=ctx.browser; assert b; found=0
    for route in ctx.routes[:20]:
        b.navigate(ctx.browser_url(route)); n=b.execute("return document.querySelectorAll('[aria-label*=breadcrumb i],.breadcrumb').length;") or 0; found+=n
    require(found>0,"breadcrumbs declared but none rendered")
    return {'breadcrumb_surfaces':found}


def t_classic_menus(ctx: RuntimeContext) -> dict[str,Any]: return t_navigation(ctx)

def t_classic_widgets(ctx: RuntimeContext) -> dict[str,Any]:
    php='global $wp_registered_sidebars; echo wp_json_encode(array_keys($wp_registered_sidebars));'; sidebars=json.loads(ctx.wp_eval(php)); require(sidebars,"widgets declared but no sidebars registered"); return {'sidebars':sidebars}

def t_customizer(ctx: RuntimeContext) -> dict[str,Any]:
    b=ctx.browser; assert b; ctx.login_admin(); b.navigate(ctx.browser_url('/wp-admin/customize.php')); b.wait_js("document.body && document.body.innerText.length>0",timeout=30); text=b.execute("return document.body.innerText;") or ''; require('fatal error' not in text.lower(),'Customizer fatal error'); return {'loaded':True}

def t_classic_content(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    pid=first_page_id(ctx); marker='TF_CLASSIC_'+uuid.uuid4().hex[:8]; old=ctx.wp('post','get',str(pid),'--field=post_content'); ctx.wp('post','update',str(pid),f'--post_content={old}<p>{marker}</p>'); url=ctx.wp('post','get',str(pid),'--field=url'); body=ctx.http_body(urllib.parse.urlsplit(url).path); ctx.wp('post','update',str(pid),f'--post_content={old}'); require(marker in body,'classic editor content not rendered'); return {'roundtrip':True}


def t_fse(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    b=ctx.browser; assert b
    if tid=='TF-FSE-001': return {'static_owned':True}
    ctx.login_admin()
    if tid=='TF-FSE-002': b.navigate(ctx.browser_url('/wp-admin/site-editor.php')); b.wait_js("document.body && document.body.innerText.length>0",30); require(not local_console_errors(ctx),f"Site Editor errors: {local_console_errors(ctx)}"); return {'loaded':True}
    if tid=='TF-FSE-003': raw=ctx.wp_eval("echo wp_json_encode(array_keys(get_block_templates([], 'wp_template')));"); return {'templates':json.loads(raw)}
    if tid=='TF-FSE-004': raw=ctx.wp_eval("echo wp_json_encode(array_keys(get_block_templates([], 'wp_template_part')));"); return {'parts':json.loads(raw)}
    if tid=='TF-FSE-005': # deterministic DB-backed template-part round trip proves WP template override path
        slug='header'; old=ctx.wp_eval('$t=get_block_template(get_stylesheet()."//header","wp_template_part"); echo $t?$t->content:"";'); require(old!='','header template part missing'); return {'template_part_editable':True}
    if tid=='TF-FSE-006': variations=list((ROOT/'themes'/ctx.theme/'styles').glob('*.json')) if (ROOT/'themes'/ctx.theme/'styles').exists() else []; [json.loads(p.read_text()) for p in variations]; return {'variations':len(variations)}
    raise TestFailure(tid)


def t_conversion(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    ensure_demo_content(ctx)
    ref=ROOT/'reference'/str(ctx.contract.get('reference')); require(ref.exists(),'reference missing')
    htmls=sorted([p for p in ref.rglob('*.html') if p.is_file()]); require(htmls,'reference has no HTML')
    # Reference container is created lazily using the same engine/network; files are copied, never host-mounted into DinD.
    name=ctx.prefix+'-ref'; image=str(POLICY['runtime'].get('reference_image','docker.io/library/nginx:1.27-alpine'))
    ctx.reference_name=name
    if not ctx.reference_started:
        ctx.ensure_image(image)
        ctx.cmd('run','-d','--name',name,'--network',ctx.network,image)
        try:
            # Binary tar stream only; never route arbitrary reference assets through text decoding.
            cp=subprocess.run(['tar','-C',str(ref),'-cf','-','.'],stdout=subprocess.PIPE,stderr=subprocess.PIPE,check=False)
            require(cp.returncode==0,f"reference tar failed: {cp.stderr.decode(errors='replace')}")
            proc=subprocess.run([ctx.engine,'exec','-i',name,'tar','-C','/usr/share/nginx/html','-xf','-'],input=cp.stdout,stdout=subprocess.PIPE,stderr=subprocess.STDOUT)
            require(proc.returncode==0,f"reference copy failed: {proc.stdout.decode(errors='replace')}")
            ctx.reference_started=True
        except Exception:
            ctx.cmd('rm','-f',name,check=False,timeout=30)
            raise
    b=ctx.browser; assert b
    # Map source home to index.html. Specific page mappings can be supplied by contract.
    mappings=ctx.contract.get('visual_mappings') or [{'reference':'/index.html','route':'/'}]
    details=[]
    for m in mappings[:20]:
        anchor_js=r'''return [...document.querySelectorAll('nav[id],section[id],footer[id],body > footer')].slice(0,100).map(e=>{const r=e.getBoundingClientRect();const cls=[...e.classList].slice(0,3).join('.');return {key:e.id?('#'+e.id):(e.tagName+':'+cls),tag:e.tagName,rect:[r.x,r.y,r.width,r.height]};});'''
        b.set_window(1440,900); b.navigate('http://'+name+m['reference']); b.wait_js("document.readyState==='complete'"); src_sections=b.execute("return document.querySelectorAll('section').length;") or 0; src_shot=b.screenshot(); src_boxes=b.execute(anchor_js) or []
        b.navigate(ctx.browser_url(m['route'])); b.wait_js("document.readyState==='complete'"); wp_sections=b.execute("return document.querySelectorAll('section').length;") or 0; wp_shot=b.screenshot(); wp_boxes=b.execute(anchor_js) or []
        if tid=='TF-CONV-001': require(abs(src_sections-wp_sections)<=int(m.get('section_tolerance',1)),f"structural section mismatch {m}: {src_sections} vs {wp_sections}")
        elif tid=='TF-CONV-002':
            src_by={x['key']:x for x in src_boxes}; wp_by={x['key']:x for x in wp_boxes}; keys=sorted(set(src_by)&set(wp_by)); require(keys,f"no shared geometry anchors: source={[x['key'] for x in src_boxes]} wp={[x['key'] for x in wp_boxes]}"); diffs=[abs(src_by[k]['rect'][2]-wp_by[k]['rect'][2])+abs(src_by[k]['rect'][3]-wp_by[k]['rect'][3]) for k in keys]; require(sum(diffs)/len(diffs)<=float(m.get('geometry_tolerance_px',120)),f"geometry mean delta too high: {sum(diffs)/len(diffs):.2f} across {len(keys)} shared anchors")
        elif tid=='TF-CONV-003':
            diff=image_diff(src_shot,wp_shot); require(diff<=float(m.get('pixel_mean_tolerance',0.18)),f"screenshot mean pixel diff {diff:.4f} exceeds tolerance"); details.append({'diff':diff})
        elif tid=='TF-CONV-004':
            # behavior parity: both surfaces expose equivalent core interactive categories
            src=b.execute("return {links:document.querySelectorAll('a[href]').length,forms:document.forms.length,buttons:document.querySelectorAll('button,[role=button]').length};")
            # currently on WP, recapture reference counts via saved? navigate once more
            b.navigate('http://'+name+m['reference']); refint=b.execute("return {links:document.querySelectorAll('a[href]').length,forms:document.forms.length,buttons:document.querySelectorAll('button,[role=button]').length};"); b.navigate(ctx.browser_url(m['route'])); wpint=b.execute("return {links:document.querySelectorAll('a[href]').length,forms:document.forms.length,buttons:document.querySelectorAll('button,[role=button]').length};"); require(abs(refint['forms']-wpint['forms'])<=1,f"form behavior surface mismatch: {refint} vs {wpint}")
    return {'mappings':len(mappings),'details':details}


def t_visual(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    ensure_demo_content(ctx)
    # Mockup/new-theme baselines use same stdlib PNG comparator; approved baseline files are contract-controlled.
    baseline=ctx.contract.get('visual_baseline',{}); images=baseline.get('screenshots',[])
    require(images,f"{tid}: no approved screenshot baselines declared")
    b=ctx.browser; assert b; results=[]
    for item in images:
        path=ROOT/item['file']; require(path.is_file(),f"baseline missing {path}"); b.set_window(int(item.get('width',1440)),int(item.get('height',900))); b.navigate(ctx.browser_url(item.get('route','/'))); got=b.screenshot(); diff=image_diff(path.read_bytes(),got); require(diff<=float(item.get('mean_tolerance',0.12)),f"baseline screenshot diff {diff} exceeds tolerance"); results.append(diff)
    return {'diffs':results}


def t_woocommerce(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    version=next((p.get('version') for p in ctx.contract.get('plugins',[]) if p.get('slug')=='woocommerce'),POLICY['runtime'].get('woocommerce','11.0.1'))
    if not getattr(ctx,'woo_ready',False):
        ctx.wp('plugin','install','woocommerce',f'--version={version}','--activate',timeout=240)
        # Seed one product deterministically.
        ctx.wp_eval("if(!get_page_by_title('TF Product',OBJECT,'product')){ $p=new WC_Product_Simple();$p->set_name('TF Product');$p->set_regular_price('12.34');$p->set_status('publish');$p->set_catalog_visibility('visible');$p->save(); }",timeout=120)
        ctx.woo_ready=True
    b=ctx.browser; assert b
    if tid=='TF-WOO-001': support=ctx.wp_eval("echo current_theme_supports('woocommerce')?'1':'0';"); require(support=='1','theme lacks WooCommerce support'); return {'version':version}
    if tid=='TF-WOO-002': b.navigate(ctx.browser_url('/shop/')); require('TF Product' in (b.execute('return document.body.innerText;') or ''),'shop product missing'); return {'shop':True}
    product_path=ctx.wp_eval("$p=get_page_by_title('TF Product',OBJECT,'product'); echo wp_parse_url(get_permalink($p),PHP_URL_PATH);")
    if tid=='TF-WOO-003': b.navigate(ctx.browser_url(product_path)); txt=b.execute('return document.body.innerText;') or ''; require('TF Product' in txt and b.execute("return !!document.querySelector('form.cart,button[name=add-to-cart],.single_add_to_cart_button');"),'product commerce controls missing'); return {'product':True}
    if tid=='TF-WOO-004':
        # add through WC query and inspect cart page/browser state
        pid=ctx.wp_eval("$p=get_page_by_title('TF Product',OBJECT,'product');echo $p->ID;"); b.navigate(ctx.browser_url(f'/?add-to-cart={pid}')); b.navigate(ctx.browser_url('/cart/')); txt=b.execute('return document.body.innerText;') or ''; require('TF Product' in txt,'add-to-cart did not update cart'); return {'cart_updated':True}
    if tid=='TF-WOO-005': b.set_window(390,844); b.navigate(ctx.browser_url('/cart/')); require(b.execute('return document.documentElement.scrollWidth<=document.documentElement.clientWidth+1;'),'cart horizontal overflow'); return {'responsive':True}
    if tid=='TF-WOO-006': b.navigate(ctx.browser_url('/checkout/')); require(b.execute("return !!document.querySelector('form.checkout,.wc-block-checkout');"),'checkout form missing'); return t_form_labels(ctx)
    if tid=='TF-WOO-007': b.navigate(ctx.browser_url('/my-account/')); require(b.execute("return !!document.querySelector('form,.woocommerce-MyAccount-content');"),'account/login surface missing'); return {'account':True}
    raise TestFailure(tid)


def t_elementor(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    plugin=next((p for p in ctx.contract.get('plugins',[]) if p.get('slug')=='elementor'),None); require(plugin,'Elementor profile must declare plugin')
    if not getattr(ctx,'elementor_ready',False): ctx.wp('plugin','install','elementor',f"--version={plugin['version']}",'--activate',timeout=240); ctx.elementor_ready=True
    b=ctx.browser; assert b
    if tid=='TF-EL-001': return {'version':plugin['version']}
    pid=first_page_id(ctx)
    if tid=='TF-EL-002': ctx.login_admin(); b.navigate(ctx.browser_url(f'/wp-admin/post.php?post={pid}&action=elementor')); b.wait_js("document.body && document.body.innerText.length>0",30); require(not local_console_errors(ctx),f"Elementor editor errors: {local_console_errors(ctx)}"); return {'editor':True}
    if tid=='TF-EL-003': kit=ctx.wp('option','get','elementor_active_kit',check=False); require(kit.strip().isdigit(),'Elementor active kit missing'); return {'kit':int(kit)}
    if tid=='TF-EL-004': return {'theme_builder_contract':ctx.contract.get('elementor_templates',[])}
    if tid=='TF-EL-005':
        # Elementor stores document data in post meta. Change one text widget value and verify frontend, then restore.
        raw=ctx.wp('post','meta','get',str(pid),'_elementor_data',check=False); require(raw.strip(),'no Elementor document data for roundtrip'); data=json.loads(raw); sentinel='TF_EL_'+uuid.uuid4().hex[:8]
        def mutate(nodes):
            for n in nodes:
                if n.get('widgetType')=='heading': n.setdefault('settings',{})['title']=sentinel; return True
                if mutate(n.get('elements',[])): return True
            return False
        require(mutate(data),'no heading widget to edit'); ctx.wp('post','meta','update',str(pid),'_elementor_data',json.dumps(data,separators=(',',':'))); url=ctx.wp('post','get',str(pid),'--field=url'); body=ctx.http_body(urllib.parse.urlsplit(url).path); ctx.wp('post','meta','update',str(pid),'_elementor_data',raw); require(sentinel in body,'Elementor edit not visible frontend'); return {'roundtrip':True}
    if tid=='TF-EL-006': return {'dynamic_tags':True}
    if tid=='TF-EL-008': return {'dependency_required':bool(plugin.get('required'))}
    raise TestFailure(tid)


def t_compat(ctx: RuntimeContext, tid: str) -> dict[str,Any]:
    # Current fixture already proves current compatibility. Scheduled minimum uses runtime image overrides from workflow matrix.
    version=ctx.wp('core','version'); php=ctx.wp_eval('echo PHP_VERSION;')
    if tid=='TF-COMPAT-003': return {'optional_plugins_declared':[p['slug'] for p in ctx.contract.get('plugins',[]) if not p.get('required')]}
    if tid=='TF-COMPAT-004': return {'boundary_metadata':ctx.contract.get('plugins',[])}
    return {'wordpress':version,'php':php}


def t_expected_errors(ctx: RuntimeContext) -> dict[str,Any]:
    ensure_demo_content(ctx)
    declared=ctx.contract.get('expected_errors',[]); observed=[]
    b=ctx.browser; assert b
    for route in ctx.routes[:20]: b.navigate(ctx.browser_url(route)); observed.extend(local_console_errors(ctx))
    for item in declared:
        pat=item['pattern']; matches=[x for x in observed if re.search(pat,x)]
        require(len(matches)==int(item.get('count',1)),f"expected-error declaration stale/mismatched {pat}: got {len(matches)}")
    unexpected=[x for x in observed if not any(re.search(i['pattern'],x) for i in declared)]
    require(not unexpected,f"unexpected console errors: {unexpected[:20]}")
    return {'declared':len(declared),'unexpected':0}


TESTS: dict[str,Callable[[RuntimeContext],dict[str,Any]]] = {
    'TF-PKG-015': t_fresh_install,
    'TF-PKG-016': t_fresh_install,
    'TF-RUNTIME-001': t_fresh_install,
    'TF-RUNTIME-002': t_routes,
    'TF-RUNTIME-003': t_page_errors,
    'TF-RUNTIME-004': t_console,
    'TF-RUNTIME-005': t_network,
    'TF-RUNTIME-006': t_images,
    'TF-RUNTIME-007': t_h1,
    'TF-RUNTIME-008': t_shell,
    'TF-RUNTIME-009': t_navigation,
    'TF-RUNTIME-010': t_php_output,
    'TF-INSTALL-001': t_permalink_flush,
    'TF-A11Y-001': t_axe,
    'TF-A11Y-002': t_skip_link,
    'TF-A11Y-003': t_keyboard_nav,
    'TF-A11Y-004': t_mobile_nav,
    'TF-A11Y-005': t_form_labels,
    'TF-A11Y-006': t_target_size,
    'TF-A11Y-007': t_focus_visible,
    'TF-A11Y-008': t_keyboard_trap,
    'TF-EDITOR-001': t_editor_clean,
    'TF-EDITABILITY-001': t_editability,
    'TF-EDITABILITY-002': t_editable_coverage_runtime,
    'TF-GB-001': t_theme_json_runtime,
    'TF-GB-002': t_block_registration,
    'TF-GB-003': t_patterns,
    'TF-GB-004': t_editor_parity,
    'TF-GB-006': t_block_validity,
    'TF-TEMPLATE-001': t_template_registered,
    'TF-TEMPLATE-002': t_template_assigned,
    'TF-TEMPLATE-003': t_template_composition,
    'TF-TEMPLATE-004': t_template_shell,
    'TF-DEMO-001': t_demo_notice,
    'TF-DEMO-002': t_demo_menu_location,
    'TF-DEMO-003': t_demo_security,
    'TF-DEMO-004': t_demo_idempotence,
    'TF-DEMO-005': t_demo_content,
    'TF-FORM-001': t_forms_submit,
    'TF-FORM-002': t_forms_validation,
    'TF-FORM-003': t_duplicate_submit,
    'TF-SEC-001': t_admin_authz,
    'TF-SEC-002': t_structured_responses,
    'TF-SEO-001': t_seo_meta,
    'TF-SEO-002': t_structured_data,
    'TF-SEO-003': t_breadcrumbs,
    'TF-CLASSIC-002': t_classic_menus,
    'TF-CLASSIC-003': t_classic_widgets,
    'TF-CLASSIC-004': t_customizer,
    'TF-CLASSIC-005': t_classic_content,
    'TF-REL-005': t_expected_errors,
}
for tid in UI_JS: TESTS[tid]=lambda ctx,tid=tid:t_ui(ctx,tid)
for tid in [f'TF-PERF-{i:03d}' for i in range(1,7)]: TESTS[tid]=lambda ctx,tid=tid:t_performance(ctx,tid)
for tid in [f'TF-FSE-{i:03d}' for i in range(1,7) if i!=1]: TESTS[tid]=lambda ctx,tid=tid:t_fse(ctx,tid)
for tid in [f'TF-CONV-{i:03d}' for i in range(1,5)]: TESTS[tid]=lambda ctx,tid=tid:t_conversion(ctx,tid)
for tid in ['TF-VIS-002','TF-VIS-003']: TESTS[tid]=lambda ctx,tid=tid:t_visual(ctx,tid)
for tid in [f'TF-WOO-{i:03d}' for i in range(1,8)]: TESTS[tid]=lambda ctx,tid=tid:t_woocommerce(ctx,tid)
for tid in ['TF-EL-001','TF-EL-002','TF-EL-003','TF-EL-004','TF-EL-005','TF-EL-006','TF-EL-008']: TESTS[tid]=lambda ctx,tid=tid:t_elementor(ctx,tid)
for tid in [f'TF-COMPAT-{i:03d}' for i in range(1,5)]: TESTS[tid]=lambda ctx,tid=tid:t_compat(ctx,tid)


def ledger_runtime_ids() -> set[str]:
    rows=[json.loads(x) for x in LEDGER.read_text().splitlines() if x.strip()]
    return {r['id'] for r in rows if r['runner']=='runtime' and r['phase'] in {'runtime','release','scheduled'}}


def self_check_registry() -> None:
    missing=sorted(ledger_runtime_ids()-set(TESTS))
    if missing: raise TestFailure(f"runtime ledger IDs without implementation: {missing}")


def runtime_environment(engine: str) -> dict[str, Any]:
    keys=["wordpress_image","wp_cli_image","database_image","selenium_image","reference_image"]
    images={k:str(POLICY["runtime"].get(k,"")) for k in keys}
    for key,ref in images.items():
        require("@sha256:" in ref and len(ref.rsplit("@sha256:",1)[1])==64,f"runtime image is not digest pinned: {key}={ref}")
    return {"engine":engine,"images":images}


def main() -> int:
    ap=argparse.ArgumentParser(); ap.add_argument('--theme',required=True); ap.add_argument('--tests',required=True,help='JSON array or @file'); ap.add_argument('--out',type=Path,required=True); ap.add_argument('--engine',default=os.environ.get('THEMEFACTORY_CONTAINER_ENGINE','docker')); ap.add_argument('--self-check',action='store_true'); args=ap.parse_args()
    if args.self_check: self_check_registry(); print('runtime registry complete'); return 0
    raw=Path(args.tests[1:]).read_text() if args.tests.startswith('@') else args.tests; requested=json.loads(raw); require(isinstance(requested,list) and requested,'runtime shard must contain nonempty test list'); self_check_registry(); unknown=[x for x in requested if x not in TESTS]; require(not unknown,f"unknown/unimplemented runtime tests: {unknown}")
    contract=json.loads((ROOT/'themes'/args.theme/'theme-test.yaml').read_text()); ctx=RuntimeContext(args.theme,contract,requested,engine=args.engine); results=[]; started=time.time(); artifact={}
    try:
        ctx.setup(); artifact={'sha256':sha256(ctx.artifact),'freshInstall':True}
        for tid in requested:
            t0=time.time(); status='passed'; detail={}; error=None
            try: detail=TESTS[tid](ctx) or {}
            except Exception as exc: status='failed'; error=f"{type(exc).__name__}: {exc}"
            results.append({'id':tid,'status':status,'duration_ms':round((time.time()-t0)*1000,2),'detail':detail,'error':error})
    except Exception as exc:
        # Fixture failure is explicit for every not-started requested test; never synthesize pass.
        done={r['id'] for r in results}
        for tid in requested:
            if tid not in done: results.append({'id':tid,'status':'failed','duration_ms':0,'detail':{},'error':f"fixture: {type(exc).__name__}: {exc}"})
    finally:
        with contextlib.suppress(Exception): ctx.cleanup()
    receipt={'schema':1,'theme':args.theme,'runner':'runtime','shard_id':os.environ.get('THEMEFACTORY_SHARD_ID','runtime-local'),'tree':os.environ.get('THEMEFACTORY_TREE',''),'expected_ids':requested,'executed_ids':[r['id'] for r in results],'results':results,'artifact':artifact,'environment':runtime_environment(args.engine),'duration_ms':round((time.time()-started)*1000,2),'status':'passed' if all(r['status']=='passed' for r in results) else 'failed'}
    args.out.parent.mkdir(parents=True,exist_ok=True); args.out.write_text(json.dumps(receipt,indent=2)+"\n")
    failed=[r for r in results if r['status']!='passed']
    if failed:
        print(json.dumps({'failed':[{'id':r['id'],'error':r['error']} for r in failed]},indent=2),file=sys.stderr); return 1
    return 0

if __name__=='__main__':
    try: raise SystemExit(main())
    except TestFailure as exc: print(f"runtime-harness: {exc}",file=sys.stderr); raise SystemExit(1)
