diff --git a/README.md b/README.md index a269ca9..7f3ba8d 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ python3 -m ampbrowser open https://wownero.org/ python3 -m ampbrowser open http://example.b32.i2p/ --yes python3 -m ampbrowser open http://example.b32.i2p/ --config examples/config.toml python3 -m ampbrowser open http://example.b32.i2p/ --platform android +python3 -m ampbrowser fixture check ../adaptive-multi-protocol-gateway/dist/wownero/ampg-fixture-manifest.json python3 -m ampbrowser docs generate python3 -m ampbrowser docs generate --check python3 tools/docs_check.py @@ -49,6 +50,7 @@ python3 -m unittest discover -s tests - [Architecture](docs/architecture.md) - [Candidate transports](docs/candidate-transports.md) - [Configuration](docs/configuration.md) +- [Fixture manifests](docs/fixture-manifests.md) - [Mobile browser](docs/mobile-browser.md) - [Open flow](docs/open-flow.md) - [Transport management](docs/transport-management.md) diff --git a/ampbrowser/cli.py b/ampbrowser/cli.py index a8389f4..cb6051d 100644 --- a/ampbrowser/cli.py +++ b/ampbrowser/cli.py @@ -6,6 +6,7 @@ import sys from .config import load_config from .docsgen import generate_docs +from .fixtures import check_fixture_manifest from .launch import prepare_open from .plan import plan_url from .platforms import PLATFORM_CHOICES @@ -43,6 +44,11 @@ def main(argv: list[str] | None = None) -> int: subcommands.add_parser("inspect", help="Inspect local transport readiness.") + fixture_parser = subcommands.add_parser("fixture", help="Check AMPG fixture manifests.") + fixture_subcommands = fixture_parser.add_subparsers(dest="fixture_command", required=True) + fixture_check = fixture_subcommands.add_parser("check", help="Validate route expectations.") + fixture_check.add_argument("manifest", type=Path) + docs_parser = subcommands.add_parser("docs", help="Generate or check generated docs.") docs_subcommands = docs_parser.add_subparsers(dest="docs_command", required=True) docs_generate = docs_subcommands.add_parser("generate", help="Generate docs from code.") @@ -64,6 +70,8 @@ def main(argv: list[str] | None = None) -> int: ) if args.command == "inspect": return _cmd_inspect() + if args.command == "fixture": + return _cmd_fixture(args) if args.command == "docs": return _cmd_docs(args) except Exception as exc: # noqa: BLE001 @@ -169,5 +177,32 @@ def _cmd_docs(args) -> int: return 1 +def _cmd_fixture(args) -> int: + if args.fixture_command == "check": + result = check_fixture_manifest(args.manifest) + for check in result.checks: + print( + "AMPBROWSER_FIXTURE " + f"site={check.site_id} " + f"protocol={check.protocol} " + f"url={check.url} " + f"expected_transport={check.expected_transport} " + f"actual_transport={check.actual_transport} " + f"expected_profile={check.expected_profile} " + f"actual_profile={check.actual_profile} " + f"status={check.status} " + f"message=\"{_safe(check.message)}\"" + ) + print( + "AMPBROWSER_FIXTURE_SUMMARY " + f"site={result.site_id} " + f"manifest={result.manifest_path} " + f"checks={len(result.checks)} " + f"status={'ok' if result.ok else 'fail'}" + ) + return 0 if result.ok else 1 + return 1 + + def _safe(value: str) -> str: return value.replace('"', "'") diff --git a/ampbrowser/fixtures.py b/ampbrowser/fixtures.py new file mode 100644 index 0000000..7c51799 --- /dev/null +++ b/ampbrowser/fixtures.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from dataclasses import dataclass +import json +from pathlib import Path +from typing import Any + +from .routing import route_url + + +SUPPORTED_SCHEMAS = {"ampg.fixture-manifest.v1"} + + +@dataclass(frozen=True) +class FixtureCheck: + site_id: str + protocol: str + url: str + expected_transport: str + actual_transport: str + expected_profile: str + actual_profile: str + status: str + message: str + + +@dataclass(frozen=True) +class FixtureCheckResult: + manifest_path: Path + schema: str + site_id: str + checks: tuple[FixtureCheck, ...] + + @property + def ok(self) -> bool: + return all(check.status == "ok" for check in self.checks) + + +def check_fixture_manifest(path: Path) -> FixtureCheckResult: + data = _load_manifest(path) + schema = _string(data, "schema") + if schema not in SUPPORTED_SCHEMAS: + raise ValueError(f"{path}: unsupported manifest schema {schema!r}") + + site = data.get("site") + if not isinstance(site, dict): + raise ValueError(f"{path}: missing site table") + site_id = _string(site, "id") + + fixtures = data.get("fixtures") + if not isinstance(fixtures, list): + raise ValueError(f"{path}: fixtures must be a list") + + checks = tuple(_check_fixture(site_id, fixture, path) for fixture in fixtures) + return FixtureCheckResult( + manifest_path=path, + schema=schema, + site_id=site_id, + checks=checks, + ) + + +def _check_fixture(site_id: str, fixture: Any, path: Path) -> FixtureCheck: + if not isinstance(fixture, dict): + raise ValueError(f"{path}: fixture entries must be tables") + protocol = _string(fixture, "protocol") + url = _string(fixture, "url") + checks = fixture.get("checks") + if not isinstance(checks, dict): + raise ValueError(f"{path}: {protocol}: missing checks table") + expected_transport = _string(checks, "transport") + expected_profile = _string(checks, "profile") + + route = route_url(url) + failures: list[str] = [] + if route.transport != expected_transport: + failures.append(f"transport expected {expected_transport} got {route.transport}") + if route.profile != expected_profile: + failures.append(f"profile expected {expected_profile} got {route.profile}") + + return FixtureCheck( + site_id=site_id, + protocol=protocol, + url=route.normalized, + expected_transport=expected_transport, + actual_transport=route.transport, + expected_profile=expected_profile, + actual_profile=route.profile, + status="fail" if failures else "ok", + message="; ".join(failures) if failures else "route matches", + ) + + +def _load_manifest(path: Path) -> dict[str, Any]: + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{path}: manifest must be a JSON object") + return data + + +def _string(data: dict[str, Any], key: str) -> str: + value = data.get(key) + if not isinstance(value, str) or not value: + raise ValueError(f"missing required string field {key!r}") + return value diff --git a/docs/fixture-manifests.md b/docs/fixture-manifests.md new file mode 100644 index 0000000..e238c69 --- /dev/null +++ b/docs/fixture-manifests.md @@ -0,0 +1,22 @@ +# Fixture Manifests + +> Status: draft | Updated 2026-07-06 | Applies to: AMPB and AMPG compatibility checks + +AMPB can check AMPG fixture manifests without contacting any network. The check confirms +that each generated fixture URL routes to the expected AMPB transport and profile. + +## Command + +```sh +python3 -m ampbrowser fixture check ../adaptive-multi-protocol-gateway/dist/wownero/ampg-fixture-manifest.json +``` + +The command exits non-zero when any fixture route does not match the manifest. + +## Scope + +Fixture checks validate route selection only. They do not verify daemon health, hidden +service reachability, IPFS pinning, TLS, or content freshness. + +The manifest must not contain private deployment notes, service keys, credentials, or +host inventory. diff --git a/docs/test-fixtures.md b/docs/test-fixtures.md index eb9bde6..53c08e1 100644 --- a/docs/test-fixtures.md +++ b/docs/test-fixtures.md @@ -23,3 +23,4 @@ degradation. - Private addresses, tunnel keys, host inventory, and credentials stay out of public docs. - Browser checks prefer dry-run plans before live launch. - Static output from AMPG should be openable by AMPB without manual proxy setup. +- AMPG fixture manifests should pass `ampbrowser fixture check`. diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..cdf432b --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from ampbrowser.fixtures import check_fixture_manifest + + +class FixtureCheckTest(unittest.TestCase): + def test_checks_ampg_manifest_routes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manifest.json" + path.write_text( + json.dumps( + { + "schema": "ampg.fixture-manifest.v1", + "site": {"id": "wownero", "domain": "wownero.org"}, + "fixtures": [ + { + "protocol": "clearnet", + "url": "https://wownero.org/", + "checks": {"transport": "clearnet", "profile": "clearnet"}, + }, + { + "protocol": "tor", + "url": "http://wownero.onion/", + "checks": {"transport": "tor", "profile": "tor"}, + }, + { + "protocol": "ipfs", + "url": "ipfs://bafyexample", + "checks": {"transport": "ipfs", "profile": "ipfs"}, + }, + ], + } + ), + encoding="utf-8", + ) + + result = check_fixture_manifest(path) + + self.assertTrue(result.ok) + self.assertEqual(3, len(result.checks)) + + def test_reports_route_mismatch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "manifest.json" + path.write_text( + json.dumps( + { + "schema": "ampg.fixture-manifest.v1", + "site": {"id": "bad", "domain": "example.test"}, + "fixtures": [ + { + "protocol": "tor", + "url": "https://example.test/", + "checks": {"transport": "tor", "profile": "tor"}, + } + ], + } + ), + encoding="utf-8", + ) + + result = check_fixture_manifest(path) + + self.assertFalse(result.ok) + self.assertEqual("fail", result.checks[0].status) + self.assertIn("transport expected tor got clearnet", result.checks[0].message) + + +if __name__ == "__main__": + unittest.main()