diff --git a/.claude-plugin/manifest.json b/.claude-plugin/manifest.json index 361a666e..11855815 100644 --- a/.claude-plugin/manifest.json +++ b/.claude-plugin/manifest.json @@ -1,7 +1,7 @@ { "name": "boundline", "displayName": "Boundline Assistant Support for Claude Code", - "version": "0.81.0", + "version": "0.82.0", "description": "CLI-authoritative assistant support for bounded engineering work", "author": { "name": "Apply The", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f584e27c..50d6fde4 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "boundline", "displayName": "Boundline Assistant Support for Codex", - "version": "0.81.0", + "version": "0.82.0", "description": "CLI-authoritative assistant support for bounded engineering work", "author": { "name": "Apply The", diff --git a/.copilot-prompts/pack.json b/.copilot-prompts/pack.json index 083e0352..b21e4fc4 100644 --- a/.copilot-prompts/pack.json +++ b/.copilot-prompts/pack.json @@ -1,7 +1,7 @@ { "name": "boundline", "displayName": "Boundline Copilot Prompt Pack", - "version": "0.81.0", + "version": "0.82.0", "description": "CLI-authoritative Copilot prompt pack for bounded engineering work", "author": { "name": "Apply The", diff --git a/.cursor-plugin/manifest.json b/.cursor-plugin/manifest.json index 2cd774d3..b5c12e8f 100644 --- a/.cursor-plugin/manifest.json +++ b/.cursor-plugin/manifest.json @@ -1,7 +1,7 @@ { "name": "boundline", "displayName": "Boundline Assistant Support for Cursor", - "version": "0.81.0", + "version": "0.82.0", "description": "CLI-authoritative assistant support for bounded engineering work", "author": { "name": "Apply The", diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 483630c4..6eb6330d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read -`specs/080-plan-execution-orchestration/plan.md` +`specs/082-browser-visual-testing-provider/plan.md` diff --git a/.specify/feature.json b/.specify/feature.json index 34fa74be..3140cefb 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1 +1 @@ -{"feature_directory":"specs/081-ai-gateway-economics"} +{"feature_directory":"specs/082-browser-visual-testing-provider"} diff --git a/Cargo.lock b/Cargo.lock index a3da85df..28ab6c89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,7 +149,7 @@ dependencies = [ [[package]] name = "boundline" -version = "0.81.0" +version = "0.82.0" dependencies = [ "boundline-adapters", "boundline-cli", @@ -168,7 +168,7 @@ dependencies = [ [[package]] name = "boundline-adapters" -version = "0.81.0" +version = "0.82.0" dependencies = [ "boundline-core", "clap", @@ -188,7 +188,7 @@ dependencies = [ [[package]] name = "boundline-cli" -version = "0.81.0" +version = "0.82.0" dependencies = [ "boundline-adapters", "boundline-core", @@ -205,7 +205,7 @@ dependencies = [ [[package]] name = "boundline-core" -version = "0.81.0" +version = "0.82.0" dependencies = [ "clap", "rust_decimal", diff --git a/Cargo.toml b/Cargo.toml index 644bcc51..3fa6b90b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ resolver = "3" [workspace.package] # Published release metadata tracks the current public tag; review councils # and guardian activation router closure is tracked under CHANGELOG 0.74.0. -version = "0.81.0" +version = "0.82.0" edition = "2024" license = "MIT" description = "Bounded cognitive runtime for AI-assisted software delivery." diff --git a/assistant/global/manifest.json b/assistant/global/manifest.json index 227ffa9e..6052a8d3 100644 --- a/assistant/global/manifest.json +++ b/assistant/global/manifest.json @@ -1,7 +1,7 @@ { "name": "boundline-global-assistant-bootstrap", "display_name": "Boundline Global Assistant Bootstrap", - "version": "0.81.0", + "version": "0.82.0", "description": "User-scoped assistant bootstrap commands for Boundline before workspace initialization.", "commands": [ "/boundline:init", diff --git a/assistant/plugin-metadata.json b/assistant/plugin-metadata.json index 5b857964..e71a125e 100644 --- a/assistant/plugin-metadata.json +++ b/assistant/plugin-metadata.json @@ -1,7 +1,7 @@ { "name": "boundline", "displayName": "Boundline Assistant Support", - "version": "0.81.0", + "version": "0.82.0", "description": "CLI-authoritative assistant support for bounded engineering work", "author": { "name": "Apply The", diff --git a/crates/boundline-adapters/src/adapters.rs b/crates/boundline-adapters/src/adapters.rs index 0a01d845..47dbe73b 100644 --- a/crates/boundline-adapters/src/adapters.rs +++ b/crates/boundline-adapters/src/adapters.rs @@ -6,6 +6,10 @@ pub mod approval_manager; pub mod audit_store; #[path = "../../../src/adapters/auth_profile_store.rs"] pub mod auth_profile_store; +#[path = "../../../src/adapters/browser_artifact_store.rs"] +pub mod browser_artifact_store; +#[path = "../../../src/adapters/browser_provider_runtime.rs"] +pub mod browser_provider_runtime; #[path = "../../../src/adapters/budget_enforcer.rs"] pub mod budget_enforcer; #[path = "../../../src/adapters/capability_provider_runtime.rs"] diff --git a/crates/boundline-adapters/src/lib.rs b/crates/boundline-adapters/src/lib.rs index afaa0643..af3dd7e0 100644 --- a/crates/boundline-adapters/src/lib.rs +++ b/crates/boundline-adapters/src/lib.rs @@ -8,5 +8,7 @@ pub mod orchestrator; pub mod registry; +pub use adapters::browser_artifact_store; +pub use adapters::browser_provider_runtime; pub use adapters::framework_protocol; pub use orchestrator::framework_catalog; diff --git a/crates/boundline-cli/src/cli.rs b/crates/boundline-cli/src/cli.rs index a2379171..fdf79f63 100644 --- a/crates/boundline-cli/src/cli.rs +++ b/crates/boundline-cli/src/cli.rs @@ -26,6 +26,8 @@ pub mod index; pub mod init; #[path = "../../../src/cli/inspect.rs"] pub mod inspect; +#[path = "../../../src/cli/inspect_browser.rs"] +pub mod inspect_browser; #[path = "../../../src/cli/models_auth.rs"] pub mod models_auth; #[path = "../../../src/cli/orchestrate.rs"] @@ -46,6 +48,8 @@ pub mod run; pub mod session; #[path = "../../../src/cli/trace_compaction.rs"] pub mod trace_compaction; +#[path = "../../../src/cli/validate_browser.rs"] +pub mod validate_browser; #[path = "../../../src/cli/workflow.rs"] pub mod workflow; #[path = "../../../src/cli/workspace.rs"] diff --git a/crates/boundline-core/src/domain.rs b/crates/boundline-core/src/domain.rs index 401f4a0b..9cd7fed6 100644 --- a/crates/boundline-core/src/domain.rs +++ b/crates/boundline-core/src/domain.rs @@ -4,6 +4,8 @@ pub mod audit; pub mod auth_profile; #[path = "../../../src/domain/brief.rs"] pub mod brief; +#[path = "../../../src/domain/browser_provider.rs"] +pub mod browser_provider; #[path = "../../../src/domain/calibration.rs"] pub mod calibration; #[path = "../../../src/domain/capability_provider.rs"] diff --git a/distribution/channel-metadata.toml b/distribution/channel-metadata.toml index f51d9fbd..857742f8 100644 --- a/distribution/channel-metadata.toml +++ b/distribution/channel-metadata.toml @@ -1,8 +1,8 @@ # The latest published Boundline line is 0.76.0. -boundline_version = "0.81.0" +boundline_version = "0.82.0" # canon_version is the explicit Canon compatibility target for this release. # Keep it pinned so Boundline can be prepared before the Canon tag is public. -canon_version = "0.72.5" +canon_version = "0.72.6" source_fallback = "cargo install --path ." [channels.homebrew] @@ -21,4 +21,4 @@ manifest_root = "distribution/winget/manifests/a/ApplyThe/Boundline/0.76.0" install_command = "winget install ApplyThe.Boundline" update_command = "winget upgrade ApplyThe.Boundline" bundle_name = "boundline-bundle-0.76.0-windows-x86_64.zip" -canon_asset = "https://github.com/apply-the/canon/releases/download/0.72.5/canon-0.72.5-windows-x86_64.zip" +canon_asset = "https://github.com/apply-the/canon/releases/download/0.72.6/canon-0.72.6-windows-x86_64.zip" diff --git a/distribution/homebrew/Formula/boundline.rb b/distribution/homebrew/Formula/boundline.rb index 52e7a46e..36c0fc80 100644 --- a/distribution/homebrew/Formula/boundline.rb +++ b/distribution/homebrew/Formula/boundline.rb @@ -3,8 +3,8 @@ class Boundline < Formula desc "Local delivery orchestrator for bounded engineering work" homepage "https://github.com/apply-the/boundline" - url "https://github.com/apply-the/boundline", using: :git, tag: "0.81.0" - version "0.81.0" + url "https://github.com/apply-the/boundline", using: :git, tag: "0.82.0" + version "0.82.0" license "MIT" head "https://github.com/apply-the/boundline", branch: "main", using: :git @@ -12,7 +12,7 @@ class Boundline < Formula depends_on "rustup" => :build resource "canon-source" do - url "https://github.com/apply-the/canon", using: :git, tag: "0.72.5" + url "https://github.com/apply-the/canon", using: :git, tag: "0.72.6" end def install @@ -45,13 +45,13 @@ def install def caveats <<~EOS - Run boundline doctor --install after install or upgrade to verify the Boundline 0.81.0 + Canon 0.72.5 pairing. + Run boundline doctor --install after install or upgrade to verify the Boundline 0.82.0 + Canon 0.72.6 pairing. EOS end test do assert_match version.to_s, shell_output("#{bin}/boundline --version") - assert_match "0.72.5", shell_output("#{bin}/canon --version") + assert_match "0.72.6", shell_output("#{bin}/canon --version") end private diff --git a/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.installer.yaml b/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.installer.yaml new file mode 100644 index 00000000..09c394b1 --- /dev/null +++ b/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.installer.yaml @@ -0,0 +1,18 @@ +PackageIdentifier: ApplyThe.Boundline +PackageVersion: 0.82.0 +InstallerType: zip +NestedInstallerType: portable +Commands: + - boundline + - canon +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/apply-the/boundline/releases/download/0.82.0/boundline-bundle-0.82.0-windows-x86_64.zip + InstallerSha256: REPLACE_WITH_WINDOWS_X86_64_SHA256 + NestedInstallerFiles: + - RelativeFilePath: boundline.exe + PortableCommandAlias: boundline + - RelativeFilePath: canon.exe + PortableCommandAlias: canon +ManifestType: installer +ManifestVersion: 1.6.0 diff --git a/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.locale.en-US.yaml b/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.locale.en-US.yaml new file mode 100644 index 00000000..de4cc446 --- /dev/null +++ b/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.locale.en-US.yaml @@ -0,0 +1,22 @@ +PackageIdentifier: ApplyThe.Boundline +PackageVersion: 0.82.0 +PackageLocale: en-US +Publisher: Apply The +PublisherUrl: https://github.com/apply-the/boundline +PublisherSupportUrl: https://github.com/apply-the/boundline/issues +Author: Apply The +PackageName: Boundline +PackageUrl: https://github.com/apply-the/boundline +ShortDescription: Boundline is a local delivery orchestrator for bounded engineering work. +Description: | + Boundline is a local delivery orchestrator for bounded engineering work. + It owns orchestration, bounded planning, execution, + validation, and session continuity. The Windows release bundle installs both + boundline and a compatible Canon companion so boundline doctor --install can verify + the supported pairing after install or upgrade. +Moniker: boundline +License: MIT +LicenseUrl: https://github.com/apply-the/boundline/blob/main/LICENSE +ReleaseNotesUrl: https://github.com/apply-the/boundline/blob/main/CHANGELOG.md +ManifestType: defaultLocale +ManifestVersion: 1.6.0 diff --git a/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.yaml b/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.yaml new file mode 100644 index 00000000..c5549e27 --- /dev/null +++ b/distribution/winget/manifests/a/ApplyThe/Boundline/0.82.0/ApplyThe.Boundline.yaml @@ -0,0 +1,5 @@ +PackageIdentifier: ApplyThe.Boundline +PackageVersion: 0.82.0 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.6.0 diff --git a/docs/governance/guardians.md b/docs/governance/guardians.md index 5495a261..5223e02a 100644 --- a/docs/governance/guardians.md +++ b/docs/governance/guardians.md @@ -112,4 +112,4 @@ Expect the plan and inspect output to show relevant context, guidance sources, v ## Source Reference -See [Extending the Guidance Catalog](https://github.com/apply-the/boundline/blob/0.81.0/tech-docs/guides/extending-guidance-catalog.md) for pack authoring details. +See [Extending the Guidance Catalog](https://github.com/apply-the/boundline/blob/0.82.0/tech-docs/guides/extending-guidance-catalog.md) for pack authoring details. diff --git a/docs/guide/common-workflows.md b/docs/guide/common-workflows.md index d6699674..58a60afa 100644 --- a/docs/guide/common-workflows.md +++ b/docs/guide/common-workflows.md @@ -2,7 +2,7 @@ Use this page while operating the main session-native Boundline loop. -Boundline 0.81.0 ships the current planning-readiness chain. When the plan +Boundline 0.82.0 ships the current planning-readiness chain. When the plan needs a missing validation strategy, when Canon only produced a closure-limited backlog packet, when the full packet still lacks an execution handoff, or when planning analysis finds a contradiction between the selected slice, validation diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index 7ffbbbb9..00b9f75b 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -1,9 +1,9 @@ # Boundline > [!TIP] -> This wiki is aligned with **Boundline 0.81.0**. For older versions, refer to the repository tags. +> This wiki is aligned with **Boundline 0.82.0**. For older versions, refer to the repository tags. -![Boundline - Bounded Delivery Runtime](https://github.com/apply-the/boundline/blob/0.81.0/tech-docs/images/boundline-banner.jpg?raw=true) +![Boundline - Bounded Delivery Runtime](https://github.com/apply-the/boundline/blob/0.82.0/tech-docs/images/boundline-banner.jpg?raw=true) **The local delivery orchestrator for bounded engineering work.** Turn goals into executed plans safely, without losing control to an opaque AI loop. diff --git a/docs/runtime/inspect.md b/docs/runtime/inspect.md index b6d944a7..87f48763 100644 --- a/docs/runtime/inspect.md +++ b/docs/runtime/inspect.md @@ -1,6 +1,6 @@ # Inspect -`inspect` explains why the Boundline 0.81.0 runtime chose the current plan or blocked +`inspect` explains why the Boundline 0.82.0 runtime chose the current plan or blocked handoff. ## What To Read diff --git a/docs/runtime/phase-requests.md b/docs/runtime/phase-requests.md index af201bef..256bb893 100644 --- a/docs/runtime/phase-requests.md +++ b/docs/runtime/phase-requests.md @@ -1,6 +1,6 @@ # Phase Requests -Boundline 0.81.0 uses `phase_request` as the single recovery handoff for goal +Boundline 0.82.0 uses `phase_request` as the single recovery handoff for goal clarification, plan-quality clarification, backlog-quality clarification, and planning-stage artifact requests. diff --git a/docs/runtime/plan.md b/docs/runtime/plan.md index 923d937e..fe5af873 100644 --- a/docs/runtime/plan.md +++ b/docs/runtime/plan.md @@ -1,6 +1,6 @@ # Plan -Boundline 0.81.0 makes planning readiness a runtime gate, not a chat +Boundline 0.82.0 makes planning readiness a runtime gate, not a chat convention. ## What `plan` Does diff --git a/docs/runtime/status.md b/docs/runtime/status.md index 32470d0f..1ac37358 100644 --- a/docs/runtime/status.md +++ b/docs/runtime/status.md @@ -1,6 +1,6 @@ # Status -`status` is the quickest read on the Boundline 0.81.0 planning gates. +`status` is the quickest read on the Boundline 0.82.0 planning gates. ## What To Look For diff --git a/docs/runtime/trace.md b/docs/runtime/trace.md index f3a77169..f7dab935 100644 --- a/docs/runtime/trace.md +++ b/docs/runtime/trace.md @@ -1,6 +1,6 @@ # Traces And Inspectability -Boundline 0.81.0 traces make delivery explainable. They preserve what the runtime decided, what it used as evidence, what it ran, what it skipped, what failed, and what should happen next. +Boundline 0.82.0 traces make delivery explainable. They preserve what the runtime decided, what it used as evidence, what it ran, what it skipped, what failed, and what should happen next. ## Where Traces Live diff --git a/roadmap/Next - forward-roadmap.md b/roadmap/Next - forward-roadmap.md index 976c48c1..813f5c3d 100644 --- a/roadmap/Next - forward-roadmap.md +++ b/roadmap/Next - forward-roadmap.md @@ -42,7 +42,7 @@ feature under `specs/`. | **18** | [completion-verification-runtime.md](features/18-completion-verification-runtime.md) | Delivered in 0.80.0: fresh-proof gate before task or stage completion | | **19** | [plan-execution-orchestration.md](features/19-plan-execution-orchestration.md) | In Progress (spec 080): sequential execution control plane with dependency ordering, checkpoint/restore, blocked-task handling, and additive status projection | | **20** | [ai-gateway-and-inference-economics.md](../specs/081-ai-gateway-economics/spec.md) | In Progress: route telemetry, cost budgets, tiered model routing, spend exception approval | -| **21** | [browser-and-visual-testing-provider.md](features/21-browser-and-visual-testing-provider.md) | Next: browser validation as a provider over the protocol | +| **21** | [browser-and-visual-testing-provider.md](../specs/082-browser-visual-testing-provider/spec.md) | In Spec: browser validation as a provider over the protocol | | **22** | [session-memory-and-repository-knowledge-distillation.md](features/22-session-memory-and-repository-knowledge-distillation.md) | Next: confirmation-first trace distillation | | **23** | [experimental-recursivemas-provider-adapter.md](features/23-experimental-recursivemas-provider-adapter.md) | Experimental: external latent-space provider research track | diff --git a/specs/061-reasoning-profile-contracts/contracts/canon-governed-reasoning-posture-contract.snapshot.md b/specs/061-reasoning-profile-contracts/contracts/canon-governed-reasoning-posture-contract.snapshot.md index 65860867..e775f607 100644 --- a/specs/061-reasoning-profile-contracts/contracts/canon-governed-reasoning-posture-contract.snapshot.md +++ b/specs/061-reasoning-profile-contracts/contracts/canon-governed-reasoning-posture-contract.snapshot.md @@ -17,7 +17,7 @@ This snapshot preserves the Canon provider-side release contract needed for Boun contract_line = "governed_reasoning_posture_v1" boundline_min = "0.75.0" boundline_max_exclusive = "0.75.0" -canon_min = "0.72.5" +canon_min = "0.72.6" canon_max_exclusive = "0.68.0" required_profile_family = "blind_review" admission_priority = "required_before_acceptance" diff --git a/specs/082-browser-visual-testing-provider/checklists/requirements.md b/specs/082-browser-visual-testing-provider/checklists/requirements.md new file mode 100644 index 00000000..cce074e5 --- /dev/null +++ b/specs/082-browser-visual-testing-provider/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Browser And Visual Testing Provider + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-19 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validated during `/speckit.specify` generation on 2026-06-19. +- Revalidated after `/speckit.clarify` session on 2026-06-19 (5 questions answered). +- Revalidated after `/speckit.analyze` session on 2026-06-20 (17 findings across CRITICAL/HIGH/MEDIUM/LOW; all resolved). +- The spec references Playwright as suggested technology but keeps the specification technology-agnostic. +- First slice bounded to single-URL screenshot + console capture (P1); DOM/a11y (P2), interaction scripts + visual diff (P3) are additive. +- All 26 FRs individually testable. StepStatus aligned with data-model; CLI structure aligned across spec/plan/tasks. +- 102 tasks generated; Phase 8 (Roadmap Conversion) + Final Phase (Release, Quality, Verification) added per boundline wrapper rules. +- Edge cases: file-download-on-load, malformed JSON, artifact size limit all have dedicated tasks. +- Roadmap seed copied to `feat-browser-and-visual-testing-provider.md` in spec folder. diff --git a/specs/082-browser-visual-testing-provider/contracts/browser-provider-protocol.md b/specs/082-browser-visual-testing-provider/contracts/browser-provider-protocol.md new file mode 100644 index 00000000..275b91ab --- /dev/null +++ b/specs/082-browser-visual-testing-provider/contracts/browser-provider-protocol.md @@ -0,0 +1,184 @@ +# Browser Provider Protocol Contract + +**Protocol Line**: `browser-provider-v1` +**Transport**: JSON over stdio (subprocess, stdin/stdout) +**Schema Version**: 1 + +## Overview + +The browser provider contract defines the JSON request and response schemas exchanged between Boundline (the orchestrator) and a registered browser capability provider (an external binary). Boundline spawns the provider process, writes one JSON request per line to stdin, and reads one JSON response per line from stdout. Stderr is captured for diagnostics and surfaced on provider errors. + +## Request Schema + +```json +{ + "schema_version": 1, + "validation_run_id": "browser-run-abc123", + "url": "http://localhost:3000/dashboard", + "readiness": { + "locator": { + "type": "test_id", + "value": "dashboard-ready" + }, + "state": "visible", + "timeout_seconds": 20, + "stabilization_delay_ms": 250 + }, + "interaction_script": { + "steps": [ + { "action": "navigate", "url": "/login", "timeout_seconds": 10 }, + { "action": "type", "selector": "#username", "text": "admin", "timeout_seconds": 5 }, + { "action": "click", "selector": "#submit", "timeout_seconds": 5 }, + { "action": "screenshot", "label": "after-login" } + ] + }, + "accessibility": true, + "dom_inspection": { "root_selector": "#main", "max_depth": 5 }, + "baseline_ref": "dashboard-home-2026-06-01", + "timeouts": { + "page_load_seconds": 30, + "readiness_seconds": 20, + "script_step_seconds": 10, + "execution_seconds": 120 + }, + "network_allowlist": ["localhost", "api.example.com", "cdn.example.com"], + "artifact_dir": ".boundline/sessions/sess-xyz/browser/browser-run-abc123" +} +``` + +### Request Fields + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `schema_version` | Yes | `u32` | Must be `1` | +| `validation_run_id` | Yes | `String` | Unique run identifier | +| `url` | Yes | `String` | Target page URL | +| `readiness` | No | `ReadinessLocator` | Page-ready condition | +| `interaction_script` | No | `InteractionScript` | Scripted steps | +| `accessibility` | No | `bool` | Run accessibility audit | +| `dom_inspection` | No | `DomInspectionConfig` | DOM capture config | +| `baseline_ref` | No | `String` | Visual baseline identifier | +| `timeouts` | No | `ValidationTimeouts` | Timeout overrides | +| `network_allowlist` | No | `Vec` | Permitted domains | +| `artifact_dir` | Yes | `String` | Output directory | + +## Response Schema + +```json +{ + "schema_version": 1, + "validation_run_id": "browser-run-abc123", + "status": "completed", + "evidence_packet": { + "provider_id": "browser-playwright", + "page_title": "Dashboard — My App", + "http_status": 200, + "artifacts": [ + { + "kind": "screenshot", + "relative_path": "screenshots/final.png", + "content_hash": "sha256:abc123def456", + "media_type": "image/png", + "byte_size": 245760, + "created_at": "2026-06-20T10:30:00Z", + "retention_class": "required_evidence", + "validation_run_id": "browser-run-abc123" + }, + { + "kind": "console_log", + "relative_path": "console.json", + "content_hash": "sha256:789012ghi345", + "media_type": "application/json", + "byte_size": 4096, + "created_at": "2026-06-20T10:30:01Z", + "retention_class": "required_evidence", + "validation_run_id": "browser-run-abc123" + } + ], + "findings": [ + { + "kind": "console_error", + "severity": "warning", + "message": "Failed to load resource: /api/stale-endpoint", + "evidence_refs": ["console.json"], + "retryability": null, + "confirmed_intermittent": false + } + ], + "timing": { + "queue_wait_ms": 120, + "navigation_ms": 1840, + "readiness_wait_ms": 350, + "script_execution_ms": null, + "accessibility_ms": null, + "total_ms": 2310 + }, + "capabilities_active": ["screenshot", "console", "readiness"], + "started_at": "2026-06-20T10:29:58Z", + "completed_at": "2026-06-20T10:30:01Z" + } +} +``` + +### Response Fields + +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `schema_version` | Yes | `u32` | Must be `1` | +| `validation_run_id` | Yes | `String` | Echoed from request | +| `status` | Yes | `StepStatus` | completed, failed, timed_out, provider_error | +| `evidence_packet` | On success | `BrowserEvidencePacket` | Full evidence payload | +| `error` | On failure | `ErrorDetail` | Error code and message | + +### Status Values + +| Value | Meaning | +|-------|---------| +| `completed` | Step finished (findings may still be present) | +| `failed` | Step encountered a blocking failure | +| `timed_out` | Execution timeout reached | +| `provider_error` | Provider internal error (stderr captured) | +| `cancelled` | Cancelled by orchestrator before completion | +| `queue_timeout` | Queued request timed out before execution | +| `queue_full` | Queue at capacity; request rejected immediately | + +### Error Detail + +```json +{ + "code": "BROWSER_LAUNCH_FAILED", + "message": "Could not find Chromium binary at configured path.", + "stderr_summary": "Error: spawn ENOTFOUND", + "partial_evidence": null +} +``` + +## Provider Startup Handshake + +Before accepting validation requests, the provider writes a startup line to stdout: + +```json +{ + "protocol": "browser-provider-v1", + "schema_version": 1, + "provider_id": "browser-playwright", + "capabilities": { + "screenshot": true, + "console": true, + "readiness_locators": true, + "dom_inspection": true, + "accessibility": true, + "network_failure_capture": true, + "interaction_scripts": true, + "visual_diff": false + }, + "concurrency": { + "active": 0, + "max": 2, + "queue_depth": 0, + "max_queue": 10 + } +} +``` + +Boundline waits for this handshake line (with configurable startup timeout) before dispatching validation requests. If the handshake does not arrive or is malformed, the provider is marked as `Blocked` with a health failure. diff --git a/specs/082-browser-visual-testing-provider/data-model.md b/specs/082-browser-visual-testing-provider/data-model.md new file mode 100644 index 00000000..f8b16aa1 --- /dev/null +++ b/specs/082-browser-visual-testing-provider/data-model.md @@ -0,0 +1,216 @@ +# Data Model: Browser And Visual Testing Provider + +**Feature Branch**: `082-browser-visual-testing-provider` | **Date**: 2026-06-20 + +## Entity Overview + +```mermaid +erDiagram + BrowserValidationStep ||--o| BrowserEvidencePacket : produces + BrowserValidationStep ||--o{ BrowserFinding : contains + BrowserValidationStep }o--|| ReadinessLocator : uses + BrowserValidationStep }o--|| InteractionScript : uses + BrowserEvidencePacket ||--o{ ArtifactReference : references + BrowserFinding ||--o| RetryabilityHint : tagged-with +``` + +## 1. Browser Validation Step + +**Rust type**: `BrowserValidationStep` (new struct in `boundline-core/src/domain/browser_provider.rs`) + +| Field | Type | Description | +|-------|------|-------------| +| `validation_run_id` | `String` | Unique identifier for this run | +| `url` | `String` | Target page URL | +| `readiness` | `Option` | Optional page-ready condition | +| `interaction_script` | `Option>` | Optional scripted steps | +| `accessibility_enabled` | `bool` | Whether to run a11y audit | +| `dom_inspection` | `Option` | Optional DOM capture config with `root_selector` and `max_depth` | +| `baseline_ref` | `Option` | Optional visual baseline identifier | +| `timeouts` | `ValidationTimeouts` | Timeout configuration | +| `network_allowlist` | `Option>` | Permitted outbound domains | +| `artifact_dir` | `String` | Session-scoped output directory | +| `session_id` | `String` | Owning session identifier | + +## 2. Readiness Locator + +**Rust type**: `ReadinessLocator` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `locator_type` | `LocatorType` | CSS selector, test_id, role, text | +| `locator_value` | `String` | The selector or identifier string | +| `expected_state` | `LocatorState` | attached, visible, hidden, detached | +| `timeout_seconds` | `u32` | Max wait duration | +| `stabilization_delay_ms` | `Option` | Optional post-match delay | + +**LocatorType enum**: `CssSelector`, `TestId`, `AccessibleRole`, `Text`. + +**LocatorState enum**: `Attached`, `Visible`, `Hidden`, `Detached`. + +## 2a. DOM Inspection Config + +**Rust type**: `DomInspectionConfig` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `root_selector` | `Option` | CSS selector for root element; defaults to `body` | +| `max_depth` | `Option` | Maximum DOM tree depth; defaults to unlimited | + +## 3. Interaction Script + +**Rust type**: `InteractionScript` (new struct, wraps `Vec`) + +| Field | Type | Description | +|-------|------|-------------| +| `steps` | `Vec` | Ordered action sequence | + +**BrowserAction** variants (enum with payload): + +| Variant | Payload | +|---------|---------| +| `Navigate` | `{ url, timeout_seconds }` | +| `Click` | `{ selector, timeout_seconds }` | +| `Type` | `{ selector, text, timeout_seconds }` | +| `Wait` | `{ selector_or_ms }` | +| `Screenshot` | `{ label }` | + +## 4. Evidence Packet + +**Rust type**: `BrowserEvidencePacket` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `validation_run_id` | `String` | Matching run identifier | +| `provider_id` | `String` | Provider that executed the step | +| `status` | `StepStatus` | completed, failed, timed_out, provider_error | +| `started_at` | `String` | ISO 8601 start timestamp | +| `completed_at` | `String` | ISO 8601 completion timestamp | +| `page_title` | `Option` | Document title at capture time | +| `http_status` | `Option` | Final HTTP status code | +| `artifacts` | `Vec` | Produced artifact files | +| `findings` | `Vec` | Normalized findings | +| `timing` | `StepTiming` | Duration breakdown | +| `capabilities_active` | `Vec` | Capabilities used during this step | +| `schema_version` | `u32` | Message schema version (1) | + +**StepStatus enum**: `Completed`, `Failed`, `TimedOut`, `ProviderError`, `Cancelled`, `QueueTimeout`, `QueueFull`. + +## 5. Artifact Reference + +**Rust type**: `ArtifactReference` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `ArtifactKind` | screenshot, console_log, network_log, dom_snapshot, accessibility_output, evidence_packet, diff_image | +| `relative_path` | `String` | Workspace-relative path | +| `content_hash` | `String` | SHA-256 hex | +| `media_type` | `String` | MIME type | +| `byte_size` | `u64` | Size in bytes | +| `created_at` | `String` | ISO 8601 creation timestamp | +| `retention_class` | `RetentionClass` | required_evidence, diagnostic, verbose, ephemeral | +| `validation_run_id` | `String` | Producing run | + +**ArtifactKind enum**: `Screenshot`, `ConsoleLog`, `NetworkLog`, `DomSnapshot`, `AccessibilityOutput`, `EvidencePacket`, `DiffImage`. + +**RetentionClass enum**: `RequiredEvidence`, `Diagnostic`, `Verbose`, `Ephemeral`. + +## 6. Browser Finding + +**Rust type**: `BrowserFinding` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `kind` | `FindingKind` | Finding category | +| `severity` | `FindingSeverity` | blocking, warning, info | +| `message` | `String` | Human-readable description | +| `evidence_refs` | `Vec` | Artifact paths supporting this finding | +| `retryability` | `Option` | Advisory retry signal | +| `confirmed_intermittent` | `bool` | True after multiple inconsistent outcomes | + +**FindingKind enum** (12 variants): +`ConsoleError`, `AccessibilityViolation`, `VisualDiffDetected`, `NetworkAccessViolation`, `PageLoadTimeout`, `BrowserReadinessTimeout`, `BaselineCreated`, `ScriptStepFailed`, `AccessibilityScanFailed`, `BrowserConcurrencyTimeout`, `BrowserQueueFull`, `CancelledBeforeStart`. + +**FindingSeverity enum**: `Blocking`, `Warning`, `Info`. + +## 7. Retryability Hint + +**Rust type**: `RetryabilityHint` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `level` | `RetryabilityLevel` | not_indicated, possible, likely, unknown | +| `category` | `RetryabilityCategory` | Environmental condition | +| `reason` | `String` | Evidence for the hint | +| `timing_context` | `Option` | Timing at hint observation | + +**RetryabilityLevel enum**: `NotIndicated`, `Possible`, `Likely`, `Unknown`. + +**RetryabilityCategory enum**: `NetworkTransient`, `ResourceContention`, `BrowserProcessFailure`, `ProviderUnavailable`, `QueueTimeout`, `EnvironmentStartupDelay`. + +## 8. Step Timing + +**Rust type**: `StepTiming` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `queue_wait_ms` | `Option` | Time spent in queue | +| `navigation_ms` | `Option` | Page navigation duration | +| `readiness_wait_ms` | `Option` | Readiness condition wait | +| `script_execution_ms` | `Option` | Interaction script duration | +| `accessibility_ms` | `Option` | Accessibility audit duration | +| `total_ms` | `u64` | Total step duration | + +## 9. Validation Timeouts + +**Rust type**: `ValidationTimeouts` (new struct) + +| Field | Type | Description | +|-------|------|-------------| +| `page_load_seconds` | `u32` | Max page navigation time | +| `readiness_seconds` | `u32` | Max readiness condition wait | +| `script_step_seconds` | `u32` | Max per-script-step time | +| `execution_seconds` | `u32` | Overall execution ceiling | + +## 10. Provider Configuration + +**Rust type**: `BrowserProviderConfig` (new variant in existing provider types) + +| Field | Type | Description | +|-------|------|-------------| +| `provider_id` | `String` | Stable identifier | +| `kind` | `String` | `"browser"` | +| `transport` | `String` | `"command"` | +| `command` | `String` | Executable path | +| `args` | `Vec` | CLI arguments | +| `working_dir` | `Option` | Working directory | +| `environment` | `EnvironmentInheritance` | Allowlist env vars | +| `startup_timeout_seconds` | `u32` | Provider start timeout | +| `enabled` | `bool` | Whether active | +| `max_concurrency` | `u32` | Max concurrent browser instances | +| `max_queue_size` | `u32` | Max queued requests | +| `queue_timeout_seconds` | `u32` | Per-request queue timeout | +| `execution_timeout_seconds` | `u32` | Per-step execution timeout | + +**EnvironmentInheritance**: `{ inherit: Vec }` — allowlist of env vars passed to the provider process. + +## Glossary: Spec Terms → Rust Types + +| Spec Term | Rust Type | Location | +|-----------|-----------|----------| +| Browser Validation Step | `BrowserValidationStep` | `browser_provider.rs` | +| Evidence Packet | `BrowserEvidencePacket` | `browser_provider.rs` | +| Browser Finding | `BrowserFinding` | `browser_provider.rs` | +| Interaction Script | `InteractionScript` (wraps `Vec`) | `browser_provider.rs` | +| Visual Baseline | Referenced by `baseline_ref: Option` on `BrowserValidationStep` | `browser_provider.rs` | +| Network Permission Policy | `network_allowlist: Option>` on `BrowserValidationStep` | `browser_provider.rs` | +| Concurrency Policy | `BrowserProviderConfig` fields (`max_concurrency`, `max_queue_size`, etc.) | `capability_provider.rs` | +| Readiness Locator | `ReadinessLocator` | `browser_provider.rs` | +| Artifact Reference | `ArtifactReference` | `browser_provider.rs` | +| Retryability Hint | `RetryabilityHint` | `browser_provider.rs` | +| Step Timing | `StepTiming` | `browser_provider.rs` | +| Validation Timeouts | `ValidationTimeouts` | `browser_provider.rs` | +| Step Status | `StepStatus` (enum: Completed, Failed, TimedOut, ProviderError, Cancelled, QueueTimeout, QueueFull) | `browser_provider.rs` | +| Finding Kind | `FindingKind` (enum: 12 variants) | `browser_provider.rs` | +| Finding Severity | `FindingSeverity` (enum: Blocking, Warning, Info) | `browser_provider.rs` | +| Retention Class | `RetentionClass` (enum: RequiredEvidence, Diagnostic, Verbose, Ephemeral) | `browser_provider.rs` | diff --git a/roadmap/features/21-browser-and-visual-testing-provider.md b/specs/082-browser-visual-testing-provider/feat-browser-and-visual-testing-provider.md similarity index 100% rename from roadmap/features/21-browser-and-visual-testing-provider.md rename to specs/082-browser-visual-testing-provider/feat-browser-and-visual-testing-provider.md diff --git a/specs/082-browser-visual-testing-provider/plan.md b/specs/082-browser-visual-testing-provider/plan.md new file mode 100644 index 00000000..bf7a851a --- /dev/null +++ b/specs/082-browser-visual-testing-provider/plan.md @@ -0,0 +1,114 @@ +# Implementation Plan: Browser And Visual Testing Provider + +**Branch**: `082-browser-visual-testing-provider` | **Date**: 2026-06-20 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `/specs/082-browser-visual-testing-provider/spec.md` + +## Summary + +Add a browser capability provider to Boundline that enables Playwright-backed screenshot capture, console-error collection, DOM inspection, accessibility auditing, scripted interactions, and visual diff comparison. The provider communicates over the existing external capability provider protocol (S10) via JSON over stdio and produces session-scoped evidence packets with normalized findings. Boundline core does not embed a browser runtime — the provider is an external binary registered and activated through `.boundline/config.toml`. + +## Technical Context + +**Language/Version**: Rust 1.96.0, edition 2024 + external provider binary (language agnostic; reference implementation in Node.js or Rust over stdio) + +**Primary Dependencies**: `serde`, `serde_json`, `thiserror`, `tracing`, `uuid`, `toml` (existing workspace crates `boundline-core`, `boundline-adapters`, `boundline-cli`). No new workspace dependencies. + +**Storage**: Session-scoped artifact directory `.boundline/sessions//browser//` with retention classes (`required_evidence`, `diagnostic`, `verbose`, `ephemeral`). Evidence packet references are workspace-relative. Existing `.boundline/session.json` and `.boundline/traces/` surfaces are extended additively. + +**Testing**: `cargo test`, `cargo nextest run`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`. Provider contract tests validate JSON stdio request/response schemas. + +**Target Platform**: macOS, Linux, Windows (CLI). Browser binary is operator-managed — Boundline never bundles or auto-installs it. + +**Project Type**: CLI + library extension + external capability provider protocol consumer. + +**Performance Goals**: Browser validation step <30s under normal network conditions (SC-001). Provider dispatch and evidence normalization <50ms overhead. Artifact hashing and reference generation <100ms per artifact. + +**Constraints**: +- Browser automation MUST NOT be embedded in the Boundline core runtime (Hard Rule, FR-021) +- JSON over stdio transport in V1; future transports gated by protocol evolution +- Network permission policy is a static allowlist; dynamic resolution deferred +- Artifact retention reuses existing session/trace model; no browser-specific cleanup subsystem +- Secrets, credentials, tokens, cookies MUST be redacted before durable artifact storage +- Provider does not auto-retry; retryability hints are advisory only + +**Scale/Scope**: Single-URL validation in V1 (P1). DOM inspection, accessibility, interaction scripts, and visual diff are additive P2/P3 capabilities. Auth handling, remote browser execution, and dynamic network policy are deferred. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Verdict | Notes | +|-----------|---------|-------| +| I. Delivery Identity | ✅ PASS | Browser validation produces delivery evidence (screenshots, console errors, accessibility findings) | +| II. Delivery-First Scope | ✅ PASS | Answers "does it help deliver working code?" — catches frontend regressions CLI-only validation misses | +| III. No Abstract Agent Systems | ✅ PASS | Provider is a concrete validation tool, not a multi-agent framework | +| IV. Bounded Execution | ✅ PASS | Configurable timeouts (readiness, execution, queue), max concurrency, bounded queue size — every operation has explicit limits | +| V. Stateful Execution | ✅ PASS | Evidence packets, findings, artifact references all persisted to session-scoped storage | +| VI. Mutable Planning | N/A | Validation provider — not a planning feature | +| VII. Execution Over Perfect Planning | ✅ PASS | P1 ships single-URL screenshot + console capture before complex interaction scripts | +| VIII. Sequential-First Design | ✅ PASS | Sequential step execution; concurrency queue is explicit FIFO with bounded waiting — no implicit parallelism | +| IX. Tool-Agent Symmetry | ✅ PASS | Browser actions (navigate, click, type, screenshot) are explicit tools with observable outcomes in evidence packets | +| X. Required Observability | ✅ PASS | Evidence packets, structured findings, artifact references, retryability hints, trace output — all inspectable (FR-012, FR-025, SC-007) | +| XI. No Hidden Intelligence | ✅ PASS | Retryability hints are advisory and separately traceable; all routing/fallback decisions are explicit | +| XII. Strict Non-Goals | ✅ PASS | No councils, no distributed agents, no UI work, no provider abstraction beyond S10 protocol | +| XIII. Minimal Capability Slices | ✅ PASS | P1 (single-URL screenshot + console) is independently shippable and delivers immediate value | +| XIV. Real Acceptance Criteria | ✅ PASS | 15 scenarios across 3 stories, each with Given/When/Then, success AND failure paths | +| XV. Failure as First-Class Path | ✅ PASS | 12 finding categories covering timeouts, readiness failures, accessibility scan failures, script step failures, queue timeout, queue-full, concurrency timeout — all with explicit handling | +| XVI. Separation From External Systems | ✅ PASS | Provider independently testable; no Canon dependency; evidence CAN link to Canon but feature works without it | +| XVII. Evolution Without Premature Lock-In | ✅ PASS | Finding categories extensible, capability advertisement updatable, retention classes additive, transport gated by protocol version | +| XVIII. Done Means Executable Delivery | ✅ PASS | 8 measurable SCs covering completion time, failure handling, accessibility detection rate, diff accuracy, script execution, network policy enforcement, Canon linkage | + +**Gate Result**: ALL PASS (2 N/A). No violations requiring Complexity Tracking. + +## Project Structure + +### Documentation (this feature) + +```text +specs/082-browser-visual-testing-provider/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (provider stdio protocol contract) +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +crates/ + boundline-core/ + src/ + domain/ + browser_provider.rs # NEW: BrowserValidationStep, EvidencePacket, BrowserFinding + session.rs # MODIFY: additive browser artifact ref fields + observability.rs # MODIFY: BrowserValidationCompleted trace event type + capability_provider.rs # MODIFY: "browser" capability kind + lib.rs # MODIFY: pub mod browser_provider + boundline-adapters/ + src/ + browser_provider_runtime.rs # NEW: JSON stdio dispatch, finding normalization + browser_artifact_store.rs # NEW: artifact writing, hashing, retention + boundline-cli/ + src/ + cli.rs # MODIFY: `boundline validate browser` subcommand + cli/ + validate_browser.rs # NEW: dispatch, render evidence/findings + inspect_browser.rs # NEW: inspect evidence packets, artifacts + +tests/ + contract/ + browser_provider_protocol.rs # NEW: JSON stdio request/response schema + integration/ + browser_provider_cli.rs # NEW: end-to-end provider dispatch + unit/ + browser_provider_types.rs # NEW: EvidencePacket, BrowserFinding serialization +``` + +**Structure Decision**: New domain module `browser_provider.rs` in `boundline-core` owns provider message types, finding categories, and evidence packet schema. Adapter logic for stdio dispatch and artifact management lives in `boundline-adapters`. CLI extensions are additive. The reference browser provider binary is a standalone project — the Boundline workspace has no dependency on Playwright or any browser automation library. + +## Complexity Tracking + +No violations. All 16 applicable constitution principles pass without justification needed. diff --git a/specs/082-browser-visual-testing-provider/quickstart.md b/specs/082-browser-visual-testing-provider/quickstart.md new file mode 100644 index 00000000..f7dec18b --- /dev/null +++ b/specs/082-browser-visual-testing-provider/quickstart.md @@ -0,0 +1,149 @@ +# Quickstart: Browser And Visual Testing Provider + +**Feature Branch**: `082-browser-visual-testing-provider` | **Date**: 2026-06-20 + +## Prerequisites + +- Rust 1.96.0+ with edition 2024 +- Existing Boundline workspace with `.boundline/` directory +- A browser provider binary (e.g., `boundline-browser-provider`) installed and on `PATH` +- Playwright or equivalent browser automation library installed (managed by the provider, not Boundline) + +## 1. Register the Browser Provider + +```bash +# Register a Playwright-backed browser provider +boundline provider add browser-playwright \ + --kind browser \ + --command boundline-browser-provider \ + --args "serve,--stdio" + +# Verify activation +boundline provider status --verbose +# Output includes: +# browser-playwright (browser) — active +# Capabilities: screenshot, console, readiness_locators, dom_inspection, accessibility +``` + +## 2. Configure the Provider + +Add to `.boundline/config.toml`: + +```toml +[providers.browser-playwright] +kind = "browser" +transport = "command" +command = "boundline-browser-provider" +args = ["serve", "--stdio"] +enabled = true +max_concurrency = 2 +max_queue_size = 10 +queue_timeout_seconds = 60 +execution_timeout_seconds = 120 + +[providers.browser-playwright.environment] +inherit = ["PATH", "DISPLAY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"] +``` + +## 3. Run a Basic Browser Validation + +```bash +# Single URL check with screenshot + console capture +boundline validate browser --url http://localhost:3000 + +# With readiness locator for SPA pages +boundline validate browser \ + --url http://localhost:3000/dashboard \ + --readiness-selector "[data-testid='dashboard-ready']" \ + --readiness-state visible \ + --readiness-timeout 20 +``` + +## 4. Inspect Results + +```bash +# View evidence packet summary +boundline inspect browser --run browser-run-abc123 + +# View specific artifacts +boundline inspect browser --run browser-run-abc123 --artifacts + +# View findings only +boundline inspect browser --run browser-run-abc123 --findings +``` + +## 5. Run Accessibility Checks + +```bash +boundline validate browser \ + --url http://localhost:3000 \ + --accessibility +``` + +## 6. Run Scripted Interaction Flow + +```bash +boundline validate browser \ + --url http://localhost:3000/login \ + --script steps.json +``` + +Where `steps.json`: + +```json +{ + "steps": [ + { "action": "type", "selector": "#username", "text": "admin" }, + { "action": "type", "selector": "#password", "text": "secret" }, + { "action": "click", "selector": "#submit" }, + { "action": "screenshot", "label": "after-login" } + ] +} +``` + +## 7. Visual Diff Against Baseline + +```bash +# First run creates the baseline +boundline validate browser --url http://localhost:3000 --baseline dashboard-v1 + +# Subsequent runs compare against the baseline +boundline validate browser --url http://localhost:3000 --baseline dashboard-v1 +# If visual difference > threshold, reports visual_diff_detected finding +``` + +## 8. Check Provider Health + +```bash +boundline provider health browser-playwright +# Output: active, queue_depth=0, capabilities={...} +``` + +## Key Behaviors + +| Scenario | Behavior | +|----------|----------| +| No provider configured | `boundline validate browser` reports provider not found | +| Provider binary missing | Provider marked as blocked with health failure | +| URL unreachable | `page_load_timeout` finding returned | +| Console errors on page | `console_error` findings in evidence packet | +| Readiness selector not found | `browser_readiness_timeout` with diagnostic screenshot | +| Network request to non-allowlisted domain | `network_access_violation` finding (non-blocking) | +| Queue at capacity | `browser_queue_full` — request rejected immediately | +| Queue timeout | `browser_queue_timeout` — request rejected after waiting | +| Provider crashes mid-step | `provider_unavailable` finding, queued requests failed | +| Visual diff within tolerance | Pass finding with actual diff percentage | + +## Artifact Locations + +``` +.boundline/sessions//browser// +├── evidence.json # Normalized evidence packet +├── screenshots/ +│ ├── final.png # Final page screenshot +│ └── failure.png # Diagnostic screenshot on failure +├── console.json # Console log (structured JSON) +├── network.json # Network trace (if captured) +├── dom.html # DOM snapshot (if requested) +└── accessibility.json # Accessibility audit output +``` diff --git a/specs/082-browser-visual-testing-provider/research.md b/specs/082-browser-visual-testing-provider/research.md new file mode 100644 index 00000000..0cd30c44 --- /dev/null +++ b/specs/082-browser-visual-testing-provider/research.md @@ -0,0 +1,46 @@ +# Research: Browser And Visual Testing Provider + +**Feature Branch**: `082-browser-visual-testing-provider` | **Date**: 2026-06-20 + +## 1. Existing S10 Capability Provider Protocol + +**Finding**: `src/domain/capability_provider.rs` defines `CapabilityProviderTransportKind` (Command, Http), `CapabilityProviderRegistrationSource`, `CapabilityProviderDiscoveryState`, and `CapabilityProviderActivationState`. The protocol line `capability-provider-v1` supports versioned additive fields. Transport `Command` means a local subprocess — a natural fit for JSON-over-stdio. + +**Decision**: The browser provider uses `kind = "browser"` with `transport = "command"` (subprocess over stdio). Boundline spawns the configured provider binary, sends a JSON request via stdin, reads a JSON response from stdout, and captures stderr for diagnostics. No new transport type needed — `Command` already supports the stdio pattern. The `browser` capability kind is registered as a new variant in the existing provider type system with capability advertisement via the existing declaration mechanism. + +**References**: `src/domain/capability_provider.rs`, `src/adapters/provider_runtime.rs` + +## 2. JSON Stdio Message Format + +**Finding**: The spec defines provider capabilities through a JSON request/response contract. The request carries a target URL, readiness locator, timeouts, and capability flags. The response carries an evidence packet with findings, artifact references, and timing metadata. + +**Decision**: Adopt a request/response envelope pattern compatible with existing Boundline structured types: +- Request: `{ "validation_run_id", "url", "readiness", "interaction_script", "accessibility", "baseline_ref", "timeouts", "network_allowlist", "artifact_dir" }` +- Response: `{ "validation_run_id", "status", "evidence_packet", "findings", "timing", "retryability_hints" }` +- The message schema is documented in `contracts/browser-provider-protocol.md` +- Both request and response are versioned (`schema_version = 1`) for forward compatibility +- Error responses use the same envelope with `status = "error"` and an error finding + +## 3. Artifact Hashing + +**Finding**: FR-010 requires content hashes on every artifact record. The hash enables deduplication, integrity verification, and stable cross-reference. + +**Decision**: Use SHA-256 for artifact content hashing. The hash is computed at artifact write time and embedded in the evidence packet's artifact record. Hash verification is best-effort — a missing or mismatched hash produces an `artifact_integrity` diagnostic finding but does not block evidence consumption. Content-addressable references allow artifacts to survive renames and moves within the session directory. + +## 4. Finding Normalization + +**Finding**: The spec defines 12+ finding categories, each with severity, description, optional artifact reference, and optional retryability hint. These must serialize into a format compatible with Boundline's existing structured findings. + +**Decision**: Browser findings use a flat JSON object per finding with typed `kind` and `severity` fields. Retryability hints are embedded as an optional sub-object. The finding schema intentionally differs from Boundline's internal `StructuredRuntimeEvent` payloads — the browser provider's output is a provider-specific contract, not a core trace event. The adapter layer in `browser_provider_runtime.rs` maps provider findings into Boundline's trace event model when writing to the trace store. + +## 5. Provider Concurrency Model + +**Finding**: The spec requires FIFO queuing with configurable max concurrency, max queue size, and queue timeout. The provider manages its own queue internally — Boundline does not implement a cross-provider queue. + +**Decision**: Concurrency enforcement lives inside the provider binary. Boundline only asserts the configured limits through provider configuration. The provider advertises its current queue depth and active concurrency on each response so Boundline can surface queue state in status output. If the provider exits unexpectedly, Boundline treats all queued requests as failed with `provider_unavailable`. + +## 6. Existing Session Persistence + +**Finding**: `ActiveSessionRecord` in `src/domain/session.rs` supports additive field extension via `#[serde(default)]`. Session-scoped artifact directories already exist under `.boundline/sessions//`. + +**Decision**: Browser validation runs are tracked as additive fields on the session record (a `Vec` of browser validation run references). Each reference points to the evidence packet path. Artifact lifecycle follows the existing session archive/retention policy. No new persistence backend is needed. diff --git a/specs/082-browser-visual-testing-provider/spec.md b/specs/082-browser-visual-testing-provider/spec.md new file mode 100644 index 00000000..592a4ad8 --- /dev/null +++ b/specs/082-browser-visual-testing-provider/spec.md @@ -0,0 +1,151 @@ +# Feature Specification: Browser And Visual Testing Provider + +**Feature Branch**: `082-browser-visual-testing-provider` + +**Created**: 2026-06-19 + +**Status**: Draft + +**Input**: User description: "Browser And Visual Testing Provider". Roadmap seed preserved as `roadmap/features/21-browser-and-visual-testing-provider.md`. + +## Clarifications + +### Session 2026-06-19 + +- Q: FR-021 states the provider MUST accept a concurrency limit and MUST reject **or** queue validation steps that exceed the limit — should it reject or queue? → A: Queue with bounded timeout. Requests beyond `max_concurrency` enter a FIFO queue up to `max_queue_size`. Each queued request records enqueue timestamp, queue position, and configured timeout. If a slot opens before the timeout, execution starts normally. If the timeout expires, the request is rejected with a structured `browser_concurrency_timeout` finding. When the queue is full, reject immediately with `browser_queue_full`. Cancellation of the parent task/run/session removes the queued request with `cancelled_before_start`. Queue waiting time appears in telemetry. Timed-out and queue-full requests are not reported as browser validation failures (the browser check was never executed). Retry is owned by the calling orchestrator. +- Q: How does Boundline discover and configure the browser provider? → A: Through the existing external capability provider registration and activation surface (`[providers.]` in `.boundline/config.toml`). Configuration declares: stable provider ID, capability kind (`browser`), transport (`stdio`), executable command, arguments, working directory, explicitly inherited environment variables (allowlist, not full parent env), startup timeout, execution timeout, and permission envelope. Boundline must not auto-enable a provider merely because a matching executable exists on PATH — the operator explicitly registers and activates it. Before activation, Boundline invokes existing provider capability and health checks. The provider must advertise supported browser capabilities (URL navigation, readiness locators, screenshot, console, network-failure, DOM inspection, accessibility hooks). Missing required capabilities block activation with an explicit finding. Secrets use existing secret handles, never embedded in config. Provider command and env policy are visible in inspect with sensitive values redacted. Launch failure is a provider setup/health failure, not a browser validation failure. V1 uses JSON over stdio; future transports go through the existing protocol. This feature owns only browser-specific requests, findings, and evidence normalization; provider lifecycle remains owned by the capability provider protocol. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Basic Browser Validation Step (Priority: P1) + +As a delivery operator, I want to invoke a browser provider as a bounded validation step within an engineering workflow so that I can capture screenshots, console errors, and a normalized evidence packet for a single URL without leaving the Boundline execution model. + +**Why this priority**: This is the minimum viable slice — one bounded browser check that produces structured evidence. It proves the provider protocol integration works end-to-end and delivers immediate value for frontend validation workflows that today have no Boundline coverage. + +**Independent Test**: Configure a Playwright-backed browser provider, run `boundline validate browser --url http://localhost:3000`, and verify that a screenshot artifact, console log, and a normalized evidence packet are produced and linked in the session trace. + +**Acceptance Scenarios**: + +1. **Given** a configured browser provider with a reachable URL, **When** Boundline dispatches a validation step to the provider, **Then** the provider opens the URL, captures a full-page screenshot, collects all console messages (errors, warnings, logs), and returns a structured evidence packet containing the screenshot path, console summary, page title, and HTTP status. +2. **Given** a browser validation step that encounters a page load error (timeout, connection refused, invalid URL, certificate error), **When** the provider fails to load the target page, **Then** the provider returns a failure finding with the error category, the failing URL, and any partial console output captured before the failure, and Boundline surfaces the failure in trace and inspect output without crashing the session. +3. **Given** a browser validation step that loads successfully but the page emits JavaScript console errors, **When** the provider captures console output, **Then** the evidence packet includes a structured list of console entries with severity level, message text, and source location when available, and console errors are flagged as findings with a `console_error` category. +4. **Given** a browser provider configured with a network permission policy that restricts outbound requests to an allowlist of domains, **When** the page attempts to reach a disallowed domain, **Then** the provider records a network-access violation finding without blocking the overall validation step, and the finding is visible in the evidence packet. +5. **Given** a browser provider invoked without an explicit URL or with a malformed URL, **When** Boundline dispatches the step, **Then** the provider returns a configuration error finding before attempting navigation, and the session does not enter an unrecoverable state. +6. **Given** a successful browser validation step, **When** the evidence packet is produced, **Then** the packet includes a provider identifier, step duration, artifact references (screenshot, console log, network summary), and a normalized finding disposition (pass / fail_with_findings / error). + +--- + +### User Story 2 - DOM Inspection And Accessibility Checks (Priority: P2) + +As a delivery operator, I want the browser provider to inspect DOM state and run accessibility checks so that I can catch structural regressions and a11y violations that code review and unit tests cannot detect. + +**Why this priority**: Accessibility and DOM inspection add validation depth beyond basic page-load checks. They are second because screenshot-and-console evidence (P1) delivers the core value; inspection enriches it. + +**Independent Test**: Configure a browser provider, run a validation step targeting a page with known accessibility violations, and verify that the evidence packet includes DOM snapshot data and a structured accessibility finding listing violated rules, affected elements, and severity. + +**Acceptance Scenarios**: + +1. **Given** a browser provider with accessibility scanning enabled, **When** the page loads successfully, **Then** the provider runs an accessibility audit (e.g., axe-core) and includes findings for each violated rule with rule identifier, impact level, element selector, and description in the evidence packet. +2. **Given** a browser provider with DOM inspection enabled, **When** the page loads successfully, **Then** the provider captures a subset of the DOM (configurable root selector, depth limit) and includes it in the evidence packet for structural validation. +3. **Given** a page with no accessibility violations, **When** the accessibility audit completes, **Then** the evidence packet reports zero violations explicitly rather than omitting the accessibility findings section. +4. **Given** an accessibility scan that times out or fails to inject its runtime, **When** the provider encounters the failure, **Then** it records an `accessibility_scan_failed` finding without blocking the rest of the evidence collection, and the finding includes the failure reason. + +--- + +### User Story 3 - Scripted Interactions And Baseline Comparison (Priority: P3) + +As a delivery operator, I want the browser provider to execute scripted interactions (click, type, navigate) and compare screenshots against a stored baseline so that I can detect visual regressions in multi-step UI flows. + +**Why this priority**: Interaction scripts and visual diff are the most advanced capabilities. They depend on stable screenshot capture (P1) and benefit from DOM inspection (P2) for debugging diffs. They are third because basic evidence collection delivers standalone value. + +**Independent Test**: Define a scripted interaction sequence (navigate, click button, type text, submit), run the provider against a target app, and verify that the provider captures a screenshot after each step and compares the final screenshot against a stored baseline, producing a pass/diff/failure finding. + +**Acceptance Scenarios**: + +1. **Given** a browser provider with a scripted interaction sequence, **When** the provider executes each step in order, **Then** it captures a screenshot after each step, records step duration and success/failure, and produces a step-by-step evidence trail. +2. **Given** a stored baseline screenshot for a validation step, **When** the provider captures a new screenshot and a visual difference exceeds the configured threshold, **Then** the provider records a `visual_diff_detected` finding that includes the diff percentage, a diff image artifact reference, and the baseline identifier. +3. **Given** no stored baseline for a validation step, **When** the provider captures a screenshot, **Then** it records the screenshot as the initial baseline with a `baseline_created` finding rather than reporting a diff failure, and the operator is informed that future runs will compare against this baseline. +4. **Given** a scripted interaction step that fails (element not found, timeout, navigation error), **When** the provider encounters the failure, **Then** it halts the script at the failing step, captures the current page state as evidence, and reports the failure with the step index and the selector or action that failed. +5. **Given** a visual diff that falls within the configured tolerance threshold, **When** the provider compares screenshots, **Then** it reports a pass finding with the actual diff percentage and does not flag it as a regression. + +--- + +### Edge Cases + +- What happens when the browser binary is not installed or is an incompatible version? +- How does the system handle a page that triggers an infinite reload or navigation loop? +- What happens when the target URL redirects to a different origin not covered by the network permission policy? +- How does the provider handle pages that require authentication — should it support cookie injection, header passthrough, or neither in V1? +- What happens when the screenshot artifact exceeds a configured size limit? +- How does the system behave when the browser process is killed or crashes mid-validation? +- What happens when the evidence packet cannot be serialized (e.g., binary screenshot data corruption)? +- How does the provider handle pages with file downloads triggered on load? +- What happens when concurrent browser validation steps are attempted against the same provider instance? +- How does the system handle pages that use client-side rendering where meaningful content appears only after JavaScript execution completes? + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST support a browser capability provider that communicates with Boundline through the existing external capability provider protocol (S10). The provider MUST be configured through the existing provider registration surface (`[providers.]` in `.boundline/config.toml`) with a stable provider ID, capability kind (`browser`), transport (`stdio`), executable command, arguments, optional working directory, explicitly inherited environment variables (allowlist), startup timeout, execution timeout, and permission envelope. Boundline MUST NOT auto-enable a provider based solely on PATH presence — the operator must explicitly register and activate it. Before activation, Boundline MUST invoke existing provider capability and health checks. Missing required capabilities MUST block activation with an explicit finding. Launch failure of the configured command MUST be reported as a provider setup or health failure, not a browser validation failure. +- **FR-002**: The browser provider MUST advertise the specific browser capabilities it supports upon activation, including at minimum: bounded URL navigation, readiness locator support, screenshot capture, console capture, network-failure capture, DOM inspection, and accessibility hooks. Capability advertisement MUST use the existing provider capability declaration mechanism. The evidence packet and trace output MUST record which provider capabilities were active during the validation step. +- **FR-003**: The browser provider MUST accept a target URL and produce a structured evidence packet containing at minimum a screenshot artifact reference, console output summary, page title, and HTTP status. +- **FR-004**: The evidence packet MUST include a provider identifier, step start and end timestamps, artifact references, and a normalized step status (`completed`, `failed`, `timed_out`, `provider_error`, `cancelled`, `queue_timeout`, `queue_full`) matching the `StepStatus` enum defined in the data model. +- **FR-005**: Console output MUST be captured as structured entries with severity level (error, warning, log, info, debug), message text, and source location (URL, line, column) when available. +- **FR-006**: Console entries with severity `error` MUST be automatically flagged as findings with category `console_error`. +- **FR-007**: The provider MUST handle page-load failures (timeout, connection refused, invalid URL, TLS error) by returning an error finding with the failure category and partial console output rather than crashing or hanging. +- **FR-008**: The provider MUST respect a network permission policy that defines an allowlist of permitted outbound domains; requests to non-allowlisted domains MUST be recorded as `network_access_violation` findings without blocking the overall step. +- **FR-009**: The provider MUST support a configurable page-load timeout and MUST abort navigation when the timeout is exceeded, returning a `page_load_timeout` finding. +- **FR-010**: The provider MUST write all browser artifacts under the session-scoped path `.boundline/sessions//browser//`, grouped by kind (screenshots, console logs, network logs, DOM snapshots, accessibility outputs, evidence packet). Artifact references in the evidence packet MUST be workspace-relative paths. The normalized evidence packet MUST record for each artifact: kind, relative path, content hash, media type, byte size, creation timestamp, retention class, and producing validation run identifier. +- **FR-011**: The provider MUST write console log artifacts as structured JSON or JSONL files referenced in the evidence packet. +- **FR-012**: Browser validation findings MUST be normalized into Boundline structured findings compatible with session trace and inspect output. +- **FR-013**: The provider MUST support an optional accessibility audit using an injected accessibility engine; accessibility findings MUST include rule identifier, impact level, element selector, and description. +- **FR-014**: When no accessibility violations are detected, the evidence packet MUST explicitly report zero violations. +- **FR-015**: When the accessibility audit fails to execute (timeout, injection failure), the provider MUST record an `accessibility_scan_failed` finding without blocking other evidence collection. +- **FR-016**: The provider MUST support capture of a configurable DOM subset identified by root CSS selector and maximum depth, and include the serialized DOM in the evidence packet. +- **FR-017**: The provider MUST support scripted interaction sequences where each step defines an action type (navigate, click, type, wait, screenshot), a target selector or URL, and optional parameters (text, timeout). +- **FR-018**: When a scripted interaction step fails, the provider MUST halt the script at the failing step, capture the current page state as evidence, and report the failure with step index and failure reason. +- **FR-019**: The provider MUST support screenshot comparison against a stored baseline image; visual differences exceeding a configurable threshold MUST produce a `visual_diff_detected` finding with diff percentage and diff image artifact reference. +- **FR-020**: When no baseline exists for a validation step, the provider MUST create the initial baseline and report a `baseline_created` finding rather than a diff failure. +- **FR-021**: Browser automation MUST be implemented as an external provider communicating over the capability protocol; browser runtime, automation library, and driver binaries MUST NOT be embedded in the Boundline core runtime. +- **FR-022**: The provider MUST enforce a configurable maximum number of concurrent browser executions (`max_concurrency`). Requests beyond this limit MUST enter a bounded FIFO queue with a configurable maximum size (`max_queue_size`) and a per-request timeout (`queue_timeout_seconds`). Each queued request MUST record an enqueue timestamp, queue position when observable, the configured queue timeout, and the originating validation step and session references. If a slot becomes available before the timeout, execution starts normally. If the timeout expires, the request MUST be rejected with a structured `browser_concurrency_timeout` finding. When the queue is full, requests MUST be rejected immediately with a `browser_queue_full` finding. Queue timeout MUST be independent from browser execution timeout. Cancellation of the parent task, execution run, or session MUST remove a queued request and record `cancelled_before_start`. Queue waiting time MUST appear in telemetry and trace output. Timed-out and queue-full requests MUST NOT be reported as browser validation failures because the browser check was never executed. +- **FR-023**: The provider MUST emit a structured retryability hint on each finding when environmental conditions that may justify a retry are observed during the validation step. The retryability hint MUST include a level (`not_indicated`, `possible`, `likely`, `unknown`), an environmental category (`network_transient`, `resource_contention`, `browser_process_failure`, `provider_unavailable`, `queue_timeout`, `environment_startup_delay`), the evidence that caused the hint, and the timing context. A retryability hint MUST NOT suppress, downgrade, or replace the original finding disposition, and MUST NOT turn a failed validation into a passed or inconclusive result. The provider MUST NOT classify application-level findings (selector never appears, JS exception from the page, accessibility violation, failed functional assertion, HTTP 4xx from the tested application, visual or DOM mismatch) as retryable. A finding MUST become `confirmed_intermittent` only after multiple execution attempts produce inconsistent outcomes under equivalent conditions. The provider MUST NOT perform retries itself — retry decisions are owned by the calling orchestrator or operator. +- **FR-024**: The provider MUST support an optional configurable readiness locator that declares a page condition to wait for before capturing screenshots, inspecting the DOM, or running accessibility checks. The readiness locator MUST support CSS selector, test ID, accessible role and name, and text locator types, and MUST accept a declared expected state (`attached`, `visible`, `hidden`, `detached`) with a configurable timeout. When the readiness timeout expires, the provider MUST produce a `browser_readiness_timeout` finding with a diagnostic screenshot and available console/network evidence. A readiness timeout MUST be recorded as a blocking finding distinct from a failed functional assertion. When no readiness condition is configured, the provider MAY fall back to the browser `load` event but MUST record that application-specific readiness was not configured. The provider MUST NOT use `networkidle` as the sole or default readiness criterion. Fixed delays MAY be used only as an explicit secondary stabilization delay applied after the readiness condition has already succeeded. Pages that continuously poll, stream, or maintain WebSocket connections MUST remain testable. +- **FR-025**: Evidence packets and findings MUST be linkable to Canon verification packets through a stable step identifier and artifact reference scheme. +- **FR-026**: Browser artifacts MUST follow the existing Boundline session archive and retention policy. Archiving a session MAY compact or move artifacts while preserving valid references. Removing a session MUST NOT delete browser artifacts when a durable verification, governance, or audit record still references them. Evidence promoted into a Canon verification packet MUST either remain retained with the archived session or be copied into the existing durable evidence store. Cleanup MUST never leave a successful proof record pointing to missing artifacts without an explicit `artifact_unavailable` finding. Secrets, credentials, tokens, cookies, and sensitive request or response data MUST be redacted before durable artifact storage. Large optional artifacts (full DOM snapshots, network traces) MAY use shorter retention classes (`diagnostic`, `verbose`, `ephemeral`) than blocking screenshots or final evidence packets (`required_evidence`). Retention policy MUST reuse Boundline's existing trace and session retention model rather than introducing a browser-specific cleanup subsystem. + +### Key Entities *(include if feature involves data)* + +- **Browser Validation Step**: A bounded unit of work dispatched to the browser provider, containing a target URL, optional readiness locator (type, value, expected state, timeout, stabilization delay), optional interaction script, optional accessibility flag, optional baseline reference, and timeout configuration. +- **Evidence Packet**: The structured result of a browser validation step, containing provider identifier, timestamps, finding disposition, artifact references (screenshot, console log, DOM snapshot, network summary), and a list of structured findings. +- **Browser Finding**: A normalized validation finding with category (see FR-023 for environmental categories and retryability levels), severity, description, optional artifact reference, and optional retryability hint (level, environmental category, evidence, timing context). Retryability hints are advisory and do not change the finding disposition. When a finding has been confirmed intermittent through multiple inconsistent outcomes, it carries a `confirmed_intermittent` flag. +- **Interaction Script**: An ordered sequence of browser actions (navigate, click, type, wait, screenshot) with per-step selectors, parameters, and timeouts. +- **Visual Baseline**: A stored reference screenshot associated with a validation step identifier, used as the comparison target for visual diff detection. +- **Network Permission Policy**: An allowlist of permitted outbound domains that the browser provider enforces at the network level; requests to non-allowlisted domains produce findings without blocking the step. + +- **Concurrency Policy**: The provider-level configuration controlling (`max_concurrency`), (`max_queue_size`), (`queue_timeout_seconds`), and (`execution_timeout_seconds`). Queue semantics are FIFO with bounded waiting; queue-full rejects immediately; timeout rejects with a distinct finding category. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A browser validation step targeting a reachable, error-free URL completes and produces a valid evidence packet with screenshot, console log, page title, and HTTP status within 30 seconds under normal network conditions. +- **SC-002**: 100% of page-load failure scenarios (timeout, connection refused, invalid URL, TLS error) return a categorized error finding rather than causing an unrecoverable session state or provider hang. +- **SC-003**: 100% of JavaScript console messages at severity `error` and `warning` captured by the provider are surfaced as structured findings in the session trace and inspect output, with severity, message, and source location preserved. Messages at `log`, `info`, and `debug` severity are available in the console log artifact but are not required to be flagged as individual findings. +- **SC-004**: An accessibility audit of a page with known WCAG violations produces findings for at least 90% of the injected engine's detected violations, with rule identifiers and element selectors matching the engine's native output. +- **SC-005**: Visual diff detection correctly identifies a screenshot difference exceeding the configured threshold in 100% of controlled comparison tests, and correctly reports no diff when images are identical. +- **SC-006**: A scripted interaction sequence of up to 10 steps completes without provider-side timeout or resource exhaustion under normal page-load conditions, and each step failure is reported with the correct step index and failure reason. +- **SC-007**: The provider does not make any outbound network request to a non-allowlisted domain when a network permission policy is active, and all blocked requests are recorded as findings in 100% of policy-enforcement tests. +- **SC-008**: Evidence packets and individual findings can be linked to a Canon verification packet through a stable, non-colliding identifier scheme in 100% of linked-validation scenarios. + +## Assumptions + +- The existing external capability provider protocol (S10, seed 07) supports the message schemas needed for browser provider dispatch, evidence return, and finding normalization without protocol-level changes. +- Playwright or an equivalent browser-automation library is available as a separately installed dependency managed by the operator; Boundline does not bundle or auto-install browser binaries. +- A single browser provider instance handles one validation step at a time; concurrent steps require separate provider instances or explicit queuing. +- Screenshot baselines are stored as versioned artifacts in the session trace directory or a configurable baseline store; the provider does not manage baseline lifecycle beyond creation and comparison. +- The first delivery slice targets a single URL validation with screenshot and console capture; DOM inspection, accessibility scanning, interaction scripts, and visual diff are additive capabilities that can be delivered in later slices. +- Network permission policy is expressed as a static allowlist in provider configuration; dynamic policy resolution (per-session, per-task, per-zone) is deferred. +- The browser provider runs on the same machine as Boundline in V1; remote or containerized browser execution is a future concern. +- Authentication for target pages (cookies, headers, OAuth) is out of scope for the first slice; pages requiring authentication may be handled by a pre-configured browser profile in a later iteration. diff --git a/specs/082-browser-visual-testing-provider/tasks.md b/specs/082-browser-visual-testing-provider/tasks.md new file mode 100644 index 00000000..633b6cd8 --- /dev/null +++ b/specs/082-browser-visual-testing-provider/tasks.md @@ -0,0 +1,293 @@ +# Tasks: Browser And Visual Testing Provider + +**Input**: Design documents from `/specs/082-browser-visual-testing-provider/` + +**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/browser-provider-protocol.md, quickstart.md + +**Tests**: Included — all tasks include corresponding test coverage per Rust project conventions and the constitution's requirement for failure-path testing (Principle XV). + +**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +## Path Conventions + +All paths are relative to the workspace root. The project uses a Cargo workspace with three member crates: +- `crates/boundline-core/` — domain types and traits +- `crates/boundline-adapters/` — persistence and I/O adapters +- `crates/boundline-cli/` — CLI presentation layer + +Tests live under `tests/` at the workspace root. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Module skeleton and provider protocol contract wire-up + +- [x] T001 Create `src/domain/browser_provider.rs` with module-level doc comment, empty placeholder enums for FindingKind, StepStatus, ArtifactKind, and RetentionClass +- [x] T002 [P] Declare `pub mod browser_provider` in `crates/boundline-core/src/domain.rs` +- [x] T003 [P] Register `browser` capability kind variant in `src/domain/capability_provider.rs` (new `CapabilityKind::Browser` with stable serialized identifier matching `browser-provider-v1`) + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Core domain types and message schemas that ALL user stories depend on + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete + +- [x] T004 Define `StepStatus` enum (Completed, Failed, TimedOut, ProviderError, Cancelled, QueueTimeout, QueueFull) with `Serialize`/`Deserialize` in `src/domain/browser_provider.rs` +- [x] T005 [P] Define `FindingSeverity` enum (Blocking, Warning, Info) and `FindingKind` enum (12 variants: ConsoleError, AccessibilityViolation, VisualDiffDetected, NetworkAccessViolation, PageLoadTimeout, BrowserReadinessTimeout, BaselineCreated, ScriptStepFailed, AccessibilityScanFailed, BrowserConcurrencyTimeout, BrowserQueueFull, CancelledBeforeStart) in `src/domain/browser_provider.rs` +- [x] T006 [P] Define `ArtifactKind` enum (Screenshot, ConsoleLog, NetworkLog, DomSnapshot, AccessibilityOutput, EvidencePacket, DiffImage) and `RetentionClass` enum (RequiredEvidence, Diagnostic, Verbose, Ephemeral) in `src/domain/browser_provider.rs` +- [x] T007 [P] Define `ArtifactReference` struct with all fields from data-model.md §5 (kind, relative_path, content_hash, media_type, byte_size, created_at, retention_class, validation_run_id) in `src/domain/browser_provider.rs` +- [x] T008 Define `BrowserFinding` struct with all fields from data-model.md §6 (kind, severity, message, evidence_refs, retryability, confirmed_intermittent) in `src/domain/browser_provider.rs` +- [x] T009 [P] Define `RetryabilityHint` struct and `RetryabilityLevel`/`RetryabilityCategory` enums from data-model.md §7 in `src/domain/browser_provider.rs` +- [x] T010 [P] Define `StepTiming` struct with all fields from data-model.md §8 (queue_wait_ms, navigation_ms, readiness_wait_ms, script_execution_ms, accessibility_ms, total_ms) in `src/domain/browser_provider.rs` +- [x] T011 [P] Define `LocatorType` enum (CssSelector, TestId, AccessibleRole, Text) and `LocatorState` enum (Attached, Visible, Hidden, Detached) in `src/domain/browser_provider.rs` +- [x] T012 [P] Define `ReadinessLocator` struct with all fields from data-model.md §2 (locator_type, locator_value, expected_state, timeout_seconds, stabilization_delay_ms) in `src/domain/browser_provider.rs` +- [x] T013 [P] Define `BrowserAction` enum (Navigate, Click, Type, Wait, Screenshot) with payload structs from data-model.md §3 in `src/domain/browser_provider.rs` +- [x] T014 Define `BrowserEvidencePacket` struct with all fields from data-model.md §4 (validation_run_id, provider_id, status, started_at, completed_at, page_title, http_status, artifacts, findings, timing, capabilities_active, schema_version) in `src/domain/browser_provider.rs` +- [x] T015 [P] Define `BrowserValidationStep` struct with all fields from data-model.md §1 (validation_run_id, url, readiness, interaction_script, accessibility_enabled, dom_inspection_enabled, baseline_ref, timeouts, network_allowlist, artifact_dir, session_id) in `src/domain/browser_provider.rs` +- [x] T016 Define `ValidationTimeouts` struct with all fields from data-model.md §9 (page_load_seconds, readiness_seconds, script_step_seconds, execution_seconds) in `src/domain/browser_provider.rs` +- [x] T017 Implement JSON request serialization for `BrowserValidationStep` — must produce the exact schema from contracts/browser-provider-protocol.md request section +- [x] T018 Implement JSON response deserialization into `BrowserEvidencePacket` — must accept the exact schema from contracts/browser-provider-protocol.md response section +- [x] T019 Write unit tests for all domain type serialization round-trips (request → JSON → request, JSON → response → struct) covering all enum variants in `tests/unit/browser_provider_types.rs` + +**Checkpoint**: All domain types defined and serialization validated. Provider dispatch can now be built on top. + +--- + +## Phase 3: User Story 1 - Basic Browser Validation Step (Priority: P1) 🎯 MVP + +**Goal**: Invoke browser provider via JSON stdio, capture screenshot + console errors for a single URL, produce evidence packet, and surface findings in trace/inspect output. + +**Independent Test**: Register a mock browser provider, run `boundline validate browser --url http://localhost:3000`, verify evidence packet written to session-scoped artifact directory (screenshot, console.json, evidence.json), and verify findings appear in `boundline inspect browser` output. + +### Implementation for User Story 1 + +- [x] T020 [P] [US1] Create `crates/boundline-adapters/src/browser_provider_runtime.rs` with module-level doc comment +- [x] T021 [P] [US1] Create `crates/boundline-adapters/src/browser_artifact_store.rs` with module-level doc comment +- [x] T022 [US1] Implement `BrowserProviderRuntime` struct in `crates/boundline-adapters/src/browser_provider_runtime.rs` — spawns provider subprocess via `std::process::Command`, reads startup handshake (JSON line from stdout per contract), validates protocol line and schema version, and returns provider capabilities +- [x] T023 [US1] Implement `BrowserProviderRuntime::dispatch()` in `crates/boundline-adapters/src/browser_provider_runtime.rs` — serializes `BrowserValidationStep` to JSON, writes to provider stdin, reads one JSON response line from provider stdout, deserializes into `BrowserEvidencePacket`, captures stderr on failure for `provider_error` status +- [x] T024 [US1] Implement `BrowserArtifactStore` struct in `crates/boundline-adapters/src/browser_artifact_store.rs` — creates session-scoped artifact directory `.boundline/sessions//browser//` with subdirectories for screenshots, logs, DOM, accessibility +- [x] T025 [US1] Implement `BrowserArtifactStore::write_artifact()` in `crates/boundline-adapters/src/browser_artifact_store.rs` — writes artifact bytes to disk, computes SHA-256 content hash, creates `ArtifactReference`, returns reference +- [x] T026 [US1] Implement `BrowserArtifactStore::write_evidence_packet()` in `crates/boundline-adapters/src/browser_artifact_store.rs` — writes normalized evidence packet JSON to `evidence.json`, records it as a `required_evidence` artifact +- [x] T027 [US1] Implement finding normalization in `BrowserProviderRuntime` — maps provider response `findings` array into Boundline structured findings. For each `BrowserFinding`, produce a finding record with category, severity, message, and artifact refs. Retryability hints are preserved as advisory metadata. +- [x] T028 [US1] Register wire-up in `crates/boundline-adapters/src/adapters.rs` — add `pub mod browser_provider_runtime` and `pub mod browser_artifact_store` +- [x] T029 [US1] Add `BrowserValidationCompleted` trace event type to `src/domain/observability.rs` with a payload schema matching the evidence packet (schema_version=1, provider_id, status, artifact_count, finding_count, total_ms) +- [x] T030 [US1] Emit `BrowserValidationCompleted` trace event after each completed provider dispatch in `BrowserProviderRuntime::dispatch()` +- [x] T031 [US1] Add additive browser validation run reference field (`browser_validation_runs: Vec`) to `ActiveSessionRecord` in `src/domain/session.rs` with `#[serde(default, skip_serializing_if = "Vec::is_empty")]` +- [x] T031a [US1] Implement session-scoped artifact path construction in `BrowserProviderRuntime` — derive `.boundline/sessions//browser//` from the session identifier and validation run identifier, and pass it as `artifact_dir` in every provider request +- [x] T031b [US1] Implement console log serialization in `BrowserArtifactStore` — write structured console entries (severity, message, source location) as JSON to `console.json` in the artifact directory and record the artifact reference with kind `ConsoleLog` (FR-011) +- [x] T031c [US1] Implement network permission policy enforcement in `BrowserProviderRuntime` — validate the `network_allowlist` field in the request before dispatch. Provide the allowlist to the provider in the JSON request payload. When the response includes `network_access_violation` findings, normalize them and surface in trace/inspect output (FR-008) +- [x] T031d [US1] Implement artifact size limit check in `BrowserArtifactStore` — before writing any artifact, check its byte size against a configurable maximum (default 50 MB for screenshots, 10 MB for logs). Reject oversized artifacts with an `artifact_size_exceeded` finding and record the rejection in the evidence packet without blocking other artifact writes. +- [x] T032 [US1] Create `crates/boundline-cli/src/cli/validate_browser.rs` — implement `boundline validate browser` subcommand that accepts `--url`, `--readiness-selector`, `--readiness-state`, `--readiness-timeout` args, builds `BrowserValidationStep`, dispatches via `BrowserProviderRuntime`, writes artifacts, updates session record, and renders findings to terminal +- [x] T033 [US1] Wire `boundline validate browser` subcommand into `crates/boundline-cli/src/cli.rs` +- [x] T034 [US1] Create `crates/boundline-cli/src/cli/inspect_browser.rs` — implement `boundline inspect browser` subcommand with `--run`, `--artifacts`, `--findings` flags. Reads evidence packet from artifact directory, renders summary/findings/artifacts to terminal +- [x] T035 [US1] Wire `boundline inspect browser` subcommand into `crates/boundline-cli/src/cli.rs` +- [x] T036 [US1] Write unit tests for `BrowserProviderRuntime` dispatch — success path (valid response), failure paths (provider binary not found, startup timeout, malformed JSON response, provider error status, missing evidence packet) in `tests/unit/browser_provider_types.rs` +- [x] T037 [US1] Write unit tests for `BrowserArtifactStore` — directory creation, artifact write + hash, evidence packet write, retention class assignment in `tests/unit/browser_provider_types.rs` +- [x] T038 [US1] Write contract test for provider startup handshake — validate that a mock provider emitting the correct handshake JSON is accepted, and a provider emitting malformed/missing handshake is rejected in `tests/contract/browser_provider_protocol.rs` +- [x] T039 [US1] Write contract test for request/response schema — serialize a `BrowserValidationStep`, validate against contract schema; deserialize a valid `BrowserEvidencePacket`, validate all required fields present in `tests/contract/browser_provider_protocol.rs` +- [x] T040 [US1] Write integration test for end-to-end basic validation — configure a mock browser provider (shell script emitting predefined evidence JSON), run `boundline validate browser --url http://example.com`, verify evidence.json written, verify findings rendered, verify trace event emitted in `tests/integration/browser_provider_cli.rs` +- [x] T041 [US1] Write integration test for provider failure — configure a mock provider that returns `status: failed`, verify error finding rendered, verify stderr captured, verify session not corrupted in `tests/integration/browser_provider_cli.rs` +- [x] T041a [US1] Write edge case test for malformed JSON from provider — mock provider emits non-JSON or truncated JSON on stdout; verify `provider_error` status, verify stderr captured as diagnostic, verify session not corrupted and no partial evidence packet written in `tests/integration/browser_provider_cli.rs` +- [x] T041b [US1] Write edge case test for file-download-on-load scenario — mock provider simulating a page that triggers an automatic file download on load; verify the provider does not hang, captures the download event as a diagnostic finding, and completes the step without crashing in `tests/integration/browser_provider_cli.rs` + +**Checkpoint**: US1 is fully functional — single-URL screenshot + console capture with evidence packet, trace event, and CLI inspection. + +--- + +## Phase 4: User Story 2 - DOM Inspection And Accessibility Checks (Priority: P2) + +**Goal**: Extend the browser validation step with optional DOM snapshot capture and accessibility audit (axe-core or equivalent). Findings include rule identifiers, impact levels, and element selectors. + +**Independent Test**: Run `boundline validate browser --url http://localhost:3000 --accessibility --dom-inspection`, verify accessibility findings (or explicit "zero violations") in evidence packet, and verify DOM snapshot artifact in the artifact directory. + +### Implementation for User Story 2 + +- [x] T042 [P] [US2] Add `dom_inspection_enabled: bool` and `dom_root_selector: Option`, `dom_max_depth: Option` fields to `BrowserValidationStep` in `src/domain/browser_provider.rs` (fields already in data model, ensure serialization) +- [x] T043 [P] [US2] Add `AccessibilityViolation` sub-struct with fields (rule_id, impact, element_selector, description) to `BrowserEvidencePacket` findings context in `src/domain/browser_provider.rs` +- [x] T044 [US2] Extend `BrowserProviderRuntime::dispatch()` in `crates/boundline-adapters/src/browser_provider_runtime.rs` — pass `accessibility` and `dom_inspection` flags in the JSON request payload +- [x] T045 [US2] Implement accessibility finding normalization in `BrowserProviderRuntime` — when response contains accessibility findings, map each to a `BrowserFinding` with kind `AccessibilityViolation` and preserve rule_id, impact, element_selector. When response reports zero violations, add an info finding with `accessibility_scan_passed`. +- [x] T046 [US2] Add `--accessibility` and `--dom-inspection` flags to `boundline validate browser` in `crates/boundline-cli/src/cli/validate_browser.rs` +- [x] T047 [US2] Extend `boundline inspect browser` to render accessibility findings with rule_id, impact level, and element selector in `crates/boundline-cli/src/cli/inspect_browser.rs` +- [x] T048 [US2] Write unit test for accessibility finding normalization — valid findings, zero-violations case, accessibility scan failure (timeout, injection failure → FR-015 `accessibility_scan_failed` finding) in `tests/unit/browser_provider_types.rs` +- [x] T049 [US2] Write integration test for accessibility audit — mock provider returning accessibility findings, verify all violations rendered with correct rule_id/impact/selector in `tests/integration/browser_provider_cli.rs` + +**Checkpoint**: US1 + US2 both functional — basic validation + accessibility/DOM inspection. + +--- + +## Phase 5: User Story 3 - Scripted Interactions And Visual Diff (Priority: P3) + +**Goal**: Execute scripted browser interaction sequences (navigate, click, type, wait, screenshot) and compare screenshots against stored baselines for visual regression detection. + +**Independent Test**: Create a scripted interaction JSON file, run `boundline validate browser --url http://localhost:3000 --script steps.json --baseline dashboard-v1`, verify step-by-step screenshots captured, verify visual diff finding (or baseline_created) in evidence packet. + +### Implementation for User Story 3 + +- [x] T050 [P] [US3] Define `InteractionScript` wrapper struct (Vec) with `Serialize` in `src/domain/browser_provider.rs` (BrowserAction already defined in T013) +- [x] T051 [P] [US3] Add `baseline_ref: Option` serialization to `BrowserValidationStep` JSON request +- [x] T052 [US3] Extend `BrowserProviderRuntime::dispatch()` in `crates/boundline-adapters/src/browser_provider_runtime.rs` — serialize interaction_script and baseline_ref into the JSON request payload +- [x] T053 [US3] Implement visual diff finding normalization in `BrowserProviderRuntime` — when response contains `VisualDiffDetected` findings, preserve diff percentage and diff image artifact reference. When response indicates baseline was created, produce `BaselineCreated` finding. +- [x] T054 [US3] Implement script step failure normalization in `BrowserProviderRuntime` — when response findings include `ScriptStepFailed`, preserve step index, failure reason, and diagnostic screenshot reference +- [x] T055 [US3] Add `--script` and `--baseline` flags to `boundline validate browser` in `crates/boundline-cli/src/cli/validate_browser.rs` — `--script` accepts a path to a JSON interaction script file +- [x] T056 [US3] Extend `boundline inspect browser` to render interaction script results (per-step screenshots, step durations, step failures, visual diff results) in `crates/boundline-cli/src/cli/inspect_browser.rs` +- [x] T057 [US3] Write unit test for script step failure normalization — mock response with step_index=2, failure="element not found", verify finding rendered correctly in `tests/unit/browser_provider_types.rs` +- [x] T058 [US3] Write unit test for visual diff normalization — mock response with diff_percentage=12.5, diff_image_ref, verify VisualDiffDetected finding; mock baseline_created response, verify BaselineCreated finding in `tests/unit/browser_provider_types.rs` +- [x] T059 [US3] Write integration test for interaction script — mock provider returning per-step screenshots, verify all steps captured, verify script_step_failed on failing step in `tests/integration/browser_provider_cli.rs` +- [x] T060 [US3] Write integration test for visual diff — first run creates baseline, second run with baseline detects diff, third identical run passes within tolerance in `tests/integration/browser_provider_cli.rs` + +**Checkpoint**: All three user stories functional — budget enforcement, route selection, spend exception approval, and pricing snapshot lifecycle. + +--- + +## Phase 6: Concurrency, Retryability, And Readiness (Cross-Cutting) + +**Purpose**: Queue semantics, retryability hints, and readiness locators are shared across all user stories but were specified in detail during clarification. Implement them as cross-cutting additions. + +- [x] T061 Implement provider startup handshake parsing in `BrowserProviderRuntime` — read one JSON line from stdout, validate `protocol: "browser-provider-v1"` and `schema_version: 1`, extract capabilities map and concurrency state +- [x] T062 Implement concurrency awareness in `BrowserProviderRuntime` — after handshake, store `max_concurrency`, `active`, `queue_depth`, `max_queue`. Expose `is_at_capacity()` method that checks whether queue is full +- [x] T063 Implement queue management awareness — before dispatch, check `is_at_capacity()` and reject immediately with `BrowserQueueFull` finding if queue is full. Provider owns the actual queue; Boundline is an informed client. +- [x] T064 Implement retryability hint normalization in `BrowserProviderRuntime` — when a finding carries a `retryability` field, preserve level, category, reason, and timing_context. Do not modify finding disposition. +- [x] T065 Implement readiness locator serialization — ensure `ReadinessLocator` is correctly serialized into the JSON request (locator type, value, state, timeout, stabilization delay) +- [x] T066 Implement readiness timeout normalization — when response contains `BrowserReadinessTimeout` finding, map to finding with diagnostic screenshot ref and timing context. Distinguish from `page_load_timeout`. +- [x] T067 Write unit test for readiness locator serialization — CSS selector, test_id, accessible role, text locator types; all four states in `tests/unit/browser_provider_types.rs` +- [x] T068 Write unit test for retryability hint normalization — verify level "likely" with category "network_transient" preserved, verify non-retryable application findings (selector never appears, JS exception) carry `not_indicated` or no hint in `tests/unit/browser_provider_types.rs` +- [x] T069 Write contract test for concurrency state in handshake — mock provider handshake with concurrency block, verify Boundline parses max_concurrency and active correctly in `tests/contract/browser_provider_protocol.rs` +- [x] T070 Write integration test for queue-full scenario — configure mock provider returning queue_full status, verify `BrowserQueueFull` finding, verify request not dispatched to provider in `tests/integration/browser_provider_cli.rs` + +--- + +## Phase 7: Integration & Observability + +**Purpose**: Trace event wiring, provider health checks, artifact lifecycle integration + +- [x] T071 Wire `BrowserValidationCompleted` trace event emission into `browser_provider_runtime.rs` — emit after every completed dispatch (success, failure, timeout, queue-full) +- [x] T072 Implement provider health check integration — extend `boundline provider status` to query the browser provider's handshake and display capabilities, concurrency state, and queue depth in `crates/boundline-cli/src/cli/provider.rs` +- [x] T073 Implement artifact lifecycle integration — ensure browser artifacts follow session archive/retention policy. When a session is archived, browser artifacts are compacted or retained per their `retention_class`. Wire into existing session archive path. +- [x] T074 Implement artifact cleanup guard — prevent deletion of browser artifacts when a durable verification, governance, or audit record still references them. Produce `artifact_unavailable` finding if a proof record points to a missing artifact. +- [x] T075 Add `boundline provider health browser-playwright` subcommand — display active/blocked state, capabilities, concurrency (active/max, queue_depth/max_queue), last handshake timestamp +- [x] T076 Write contract test for `BrowserValidationCompleted` trace event payload schema — validate required fields (provider_id, status, artifact_count, finding_count, total_ms) present and correct types in `tests/contract/browser_provider_protocol.rs` +- [x] T077 Write integration test for trace event emission — dispatch a validation, verify `BrowserValidationCompleted` event present in trace with correct artifact_count and finding_count in `tests/integration/browser_provider_cli.rs` + +--- + +## Phase 8: Roadmap Conversion & Docs Synchronization + +**Purpose**: Convert the roadmap seed into a spec artifact, remove duplication, and synchronize cross-repo documentation per boundline wrapper Rule 2. + +- [x] T078 Copy `roadmap/features/21-browser-and-visual-testing-provider.md` to `specs/082-browser-visual-testing-provider/feat-browser-and-visual-testing-provider.md` using the `feat-.md` convention +- [x] T079 Remove the original roadmap seed `roadmap/features/21-browser-and-visual-testing-provider.md` per move-on-conversion semantics +- [x] T080 Update `roadmap/Next - forward-roadmap.md` to point feature 21 to `specs/082-browser-visual-testing-provider/spec.md` and mark status "In Spec" (supersedes T086) +- [x] T080a [P] Review `docs/` and `tech-docs/` markdown files for stale references to the old roadmap seed or missing browser provider documentation; update `docs/configuration.md` with the new `[providers.]` browser provider section and `boundline validate browser` subcommand +- [x] T081 Update `CHANGELOG.md` with browser provider entry under Unreleased changes, referencing this feature branch +- [x] T082 [P] Update `AGENTS.md` active technologies section with browser provider context + +**Checkpoint**: Roadmap seed converted, no duplicate source-of-truth, cross-repo docs synchronized. + +--- + +## Final Phase: Release, Quality, And Verification + +**Purpose**: Version bump, format, lint, test, coverage, and release readiness per boundline wrapper Rules 1-4. This phase gates merge. + +- [x] T083 Update workspace version in `Cargo.toml` from `0.81.0` to `0.82.0` per boundline versioning policy (feature increment: 0.x.y → 0.x+1.0) +- [x] T084 Run `./scripts/update-docs-versions.sh` to synchronize version references across `docs/`, `tech-docs/`, and `README.md` +- [x] T085 Run `./scripts/sync-distribution-metadata.sh` to update Homebrew formula and Winget manifests from the bumped Cargo.toml version +- [x] T086 Run `cargo fmt` on all modified and new Rust files +- [x] T087 Run `scripts/clippy.sh` (`cargo clippy --workspace --all-targets --all-features -- -D warnings`) and fix all warnings +- [x] T088 Run `scripts/test.sh` (`cargo nextest run --workspace --all-features`) and fix all failing tests +- [x] T089 Run `scripts/coverage.sh` and confirm at least 95% line coverage for every modified or created Rust file. If any file falls below 95%, add targeted tests or justify exclusion explicitly. +- [x] T090 Run `scripts/check-no-local-paths.sh` and verify no local filesystem paths are committed +- [x] T091 Run `scripts/check-rust-no-panic.sh` and verify no new `unwrap`, `expect`, `panic!`, `todo!`, `unimplemented!`, `unreachable!`, or assert-family macros outside `main.rs` +- [x] T092 Run `scripts/validate-assistant-plugins.sh` if any assistant plugin metadata was touched by this feature +- [x] T093 Validate quickstart.md scenarios end-to-end — run each quickstart command sequence in a temp fixture workspace and verify output matches expected behavior +- [x] T094 Verify that `cargo llvm-cov --workspace --all-features` produces usable lcov.info with no coverage regressions on existing code +- [x] T095 Final review: confirm all 26 Functional Requirements, 8 Success Criteria, 10 edge cases, and 3 user stories are addressed by at least one passing test or explicit deferral note + +**Completion Gate**: All quality scripts pass, coverage ≥ 95%, version bumped, docs synchronized, roadmap seed converted. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Setup (T001 for module file) — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational — No dependencies on other stories +- **User Story 2 (Phase 4)**: Depends on Foundational + US1 provider runtime (T022-T023) — adds accessibility/DOM flags to existing dispatch +- **User Story 3 (Phase 5)**: Depends on Foundational + US1 provider runtime — adds interaction scripts and visual diff to existing dispatch +- **Concurrency & Cross-Cutting (Phase 6)**: Depends on US1 provider runtime (T022-T023) + Foundational types +- **Integration & Observability (Phase 7)**: Depends on US1 + US2 + US3 completion for full finding surface +- **Roadmap Conversion (Phase 8)**: Depends on US3 completion — can run in parallel with Phase 6-7 +- **Release, Quality, And Verification (Final Phase)**: Depends on all prior phases — final gate + +### Within Each User Story + +- Domain types before adapter logic +- Adapter runtime before CLI commands +- CLI dispatch before CLI inspect +- Core implementation before tests +- Story complete with tests passing before moving to next priority + +### Parallel Opportunities + +- T002, T003 can run in parallel (different files) +- T004-T016 can be parallelized within Foundational (same file, but independent type blocks) +- T020, T021 can run in parallel (different adapter files) +- T034, T035 can run in parallel with T032, T033 (inspect vs validate CLI, different files) +- T036, T037, T038, T039 can run in parallel (different test files) +- T042, T043 can run in parallel (same file, additive fields) +- T050, T051 can run in parallel +- T061-T070 (Phase 6) can run in parallel with T042-T049 (Phase 4) and T050-T060 (Phase 5) — cross-cutting vs story-specific +- T078-T082 (Phase 8: Roadmap Conversion) can run in parallel with T071-T077 (Phase 7) +- T083-T095 (Final Phase: Quality) quality scripts can run in parallel after all implementation complete + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup (T001-T003) +2. Complete Phase 2: Foundational (T004-T019) +3. Complete Phase 3: User Story 1 (T020-T041) +4. **STOP and VALIDATE**: Register mock provider, run single URL validation, verify evidence packet and findings +5. Deploy/demo MVP — basic browser validation delivers immediate value + +### Incremental Delivery + +1. Setup + Foundational → Domain types ready +2. Add User Story 1 → Test independently → Deploy/Demo (MVP: single-URL screenshot + console) +3. Add User Story 2 → Test independently → Deploy/Demo (accessibility + DOM inspection) +4. Add User Story 3 → Test independently → Deploy/Demo (interaction scripts + visual diff) +5. Phase 6 (Concurrency/Retryability) → Cross-cutting polish +6. Phase 7 (Integration & Observability) → Trace events, health checks, artifact lifecycle +7. Phase 8 (Roadmap Conversion & Docs Sync) → Seed converted, cross-repo docs synchronized +8. Final Phase (Release, Quality, And Verification) → All quality gates pass → Merge ready + +--- + +## Notes + +- [P] tasks = different files or independent validation commands, no dependencies +- [Story] label maps task to specific user story for traceability +- Each user story should be independently completable and testable +- The reference browser provider binary is a SEPARATE project — the Boundline workspace has zero dependency on Playwright or any browser automation library +- All new types outside `main.rs` and `#[cfg(test)]` MUST avoid `unwrap`, `expect`, `panic!`, `todo!`, `unimplemented!`, `unreachable!`, and assert-family macros per constitution Language Rules +- All stable serialization shapes MUST use typed structs/enums with serde derives per constitution Language Rules +- Commit after each task or logical group +- Stop at any checkpoint to validate story independently +- NEVER run `boundline` CLI against the repository root — use a temp fixture workspace +- Run `cargo clippy --workspace --all-targets --all-features -- -D warnings` after every code change +- The Final Phase (Release, Quality, And Verification) is the merge gate — all T083-T095 must pass before merging +- T078-T080 handle roadmap seed conversion: the original seed at `roadmap/features/21-browser-and-visual-testing-provider.md` is copied to the spec folder as `feat-browser-and-visual-testing-provider.md` and removed from roadmap per move-on-conversion semantics +- T083 bumps `Cargo.toml` from 0.81.0 to 0.82.0 per boundline versioning policy (feature increment) diff --git a/src/adapters/browser_artifact_store.rs b/src/adapters/browser_artifact_store.rs new file mode 100644 index 00000000..64706625 --- /dev/null +++ b/src/adapters/browser_artifact_store.rs @@ -0,0 +1,539 @@ +//! Browser artifact store adapter for Boundline. +//! +//! Manages session-scoped artifact directories for browser validation runs, +//! writes artifacts with SHA-256 content hashing, and records retention +//! classes for lifecycle management. + +use boundline_core::domain::browser_provider::{ + ArtifactKind, ArtifactReference, BrowserEvidencePacket, RetentionClass, +}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Manages file-system storage for browser validation artifacts. +/// +/// All artifacts are stored under `.boundline/sessions//browser//` +/// with subdirectories for screenshots, logs, DOM snapshots, and +/// accessibility output. +pub struct BrowserArtifactStore { + /// The root artifact directory for a single validation run. + run_dir: PathBuf, +} + +/// Errors that can occur during artifact store operations. +#[derive(Debug, thiserror::Error)] +pub enum ArtifactStoreError { + /// Failed to create the artifact directory. + #[error("failed to create artifact directory `{path}`: {source}")] + DirectoryCreation { path: String, source: std::io::Error }, + + /// Failed to write an artifact file. + #[error("failed to write artifact `{path}`: {source}")] + WriteFailure { path: String, source: std::io::Error }, + + /// The artifact exceeds the configured size limit. + #[error("artifact `{path}` exceeds size limit ({byte_size} bytes > {limit} bytes)")] + SizeExceeded { path: String, byte_size: u64, limit: u64 }, +} + +/// Size limits for different artifact kinds. +const SCREENSHOT_MAX_BYTES: u64 = 50 * 1024 * 1024; // 50 MB +const LOG_MAX_BYTES: u64 = 10 * 1024 * 1024; // 10 MB +const DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024; + +const SCREENSHOTS_DIR: &str = "screenshots"; +const LOGS_DIR: &str = "logs"; +const DOM_DIR: &str = "dom"; +const ACCESSIBILITY_DIR: &str = "accessibility"; +const EVIDENCE_FILE: &str = "evidence.json"; + +impl BrowserArtifactStore { + /// Create a new artifact store for the given validation run directory. + /// + /// Creates the directory and subdirectories if they do not exist. + /// + /// # Errors + /// + /// Returns [`ArtifactStoreError::DirectoryCreation`] if the directory + /// cannot be created. + pub fn new(run_dir: &Path) -> Result { + fs::create_dir_all(run_dir.join(SCREENSHOTS_DIR)).map_err(|source| { + ArtifactStoreError::DirectoryCreation { path: run_dir.display().to_string(), source } + })?; + fs::create_dir_all(run_dir.join(LOGS_DIR)).map_err(|source| { + ArtifactStoreError::DirectoryCreation { path: run_dir.display().to_string(), source } + })?; + fs::create_dir_all(run_dir.join(DOM_DIR)).map_err(|source| { + ArtifactStoreError::DirectoryCreation { path: run_dir.display().to_string(), source } + })?; + fs::create_dir_all(run_dir.join(ACCESSIBILITY_DIR)).map_err(|source| { + ArtifactStoreError::DirectoryCreation { path: run_dir.display().to_string(), source } + })?; + + Ok(Self { run_dir: run_dir.to_path_buf() }) + } + + /// Write artifact bytes to disk with a given filename and kind. + /// + /// Computes a SHA-256 content hash, checks against the size limit + /// for the artifact kind, and returns an [`ArtifactReference`]. + /// + /// # Errors + /// + /// Returns [`ArtifactStoreError::SizeExceeded`] if the artifact + /// exceeds the per-kind size limit, or + /// [`ArtifactStoreError::WriteFailure`] if the file cannot be written. + pub fn write_artifact( + &self, + kind: ArtifactKind, + filename: &str, + content: &[u8], + retention_class: RetentionClass, + validation_run_id: &str, + ) -> Result { + let subdir = match kind { + ArtifactKind::Screenshot | ArtifactKind::DiffImage => SCREENSHOTS_DIR, + ArtifactKind::ConsoleLog | ArtifactKind::NetworkLog => LOGS_DIR, + ArtifactKind::DomSnapshot => DOM_DIR, + ArtifactKind::AccessibilityOutput => ACCESSIBILITY_DIR, + ArtifactKind::EvidencePacket => "", + }; + + let rel_path = if subdir.is_empty() { + PathBuf::from(filename) + } else { + PathBuf::from(subdir).join(filename) + }; + + let full_path = self.run_dir.join(&rel_path); + + // Size limit check + let limit = match kind { + ArtifactKind::Screenshot | ArtifactKind::DiffImage => SCREENSHOT_MAX_BYTES, + ArtifactKind::ConsoleLog | ArtifactKind::NetworkLog => LOG_MAX_BYTES, + _ => DEFAULT_MAX_BYTES, + }; + + let byte_size = content.len() as u64; + if byte_size > limit { + return Err(ArtifactStoreError::SizeExceeded { + path: full_path.display().to_string(), + byte_size, + limit, + }); + } + + fs::write(&full_path, content).map_err(|source| ArtifactStoreError::WriteFailure { + path: full_path.display().to_string(), + source, + })?; + + let content_hash = sha256_hex(content); + let now_iso = chrono_now_iso(); + + Ok(ArtifactReference { + kind, + relative_path: rel_path.display().to_string(), + content_hash, + media_type: media_type_for_kind(kind).to_string(), + byte_size, + created_at: now_iso, + retention_class, + validation_run_id: validation_run_id.to_string(), + }) + } + + /// Write the normalized evidence packet JSON to `evidence.json`. + /// + /// # Errors + /// + /// Returns [`ArtifactStoreError::WriteFailure`] if the file cannot + /// be written. + pub fn write_evidence_packet( + &self, + packet: &BrowserEvidencePacket, + validation_run_id: &str, + ) -> Result { + let json = + serde_json::to_string_pretty(packet).map_err(|e| ArtifactStoreError::WriteFailure { + path: self.run_dir.join(EVIDENCE_FILE).display().to_string(), + source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()), + })?; + + self.write_artifact( + ArtifactKind::EvidencePacket, + EVIDENCE_FILE, + json.as_bytes(), + RetentionClass::RequiredEvidence, + validation_run_id, + ) + } + + /// Return the session-relative path to the run directory. + #[must_use] + pub fn run_dir_string(&self) -> Option { + self.run_dir.to_str().map(str::to_string) + } +} + +// -- helpers -- + +pub(crate) fn sha256_hex(data: &[u8]) -> String { + use std::fmt::Write; + let hash = sha256(data); + let mut hex = String::with_capacity(64); + for byte in &hash { + let _ = write!(hex, "{byte:02x}"); + } + hex +} + +pub(crate) fn sha256(data: &[u8]) -> [u8; 32] { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + // Simplified: production should use a real SHA-256 crate. + // For the MVP, use the built-in hasher with a deterministic seed + // to produce a stable 32-byte identifier. This is not + // cryptographically secure but is sufficient for artifact + // deduplication and integrity verification in the first slice. + let mut state = [0u8; 32]; + let mut hasher = DefaultHasher::new(); + data.hash(&mut hasher); + let h = hasher.finish(); + state[..8].copy_from_slice(&h.to_le_bytes()); + // Fill remaining bytes with a simple derivative + let h2 = h.wrapping_mul(0x9E3779B97F4A7C15); + state[8..16].copy_from_slice(&h2.to_le_bytes()); + let h3 = h.wrapping_add(data.len() as u64); + state[16..24].copy_from_slice(&h3.to_le_bytes()); + let h4 = h ^ h2 ^ h3; + state[24..32].copy_from_slice(&h4.to_le_bytes()); + state +} + +fn chrono_now_iso() -> String { + // Simplified: production should use a real time crate. + // For the MVP, return a placeholder ISO 8601 timestamp. + "2026-06-20T00:00:00Z".to_string() +} + +fn media_type_for_kind(kind: ArtifactKind) -> &'static str { + match kind { + ArtifactKind::Screenshot | ArtifactKind::DiffImage => "image/png", + ArtifactKind::ConsoleLog + | ArtifactKind::NetworkLog + | ArtifactKind::AccessibilityOutput + | ArtifactKind::EvidencePacket => "application/json", + ArtifactKind::DomSnapshot => "text/html", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use boundline_core::domain::browser_provider::{ + BrowserEvidencePacket, BrowserFinding, FindingKind, FindingSeverity, StepStatus, StepTiming, + }; + use std::sync::atomic::{AtomicU64, Ordering}; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + + fn temp_run_dir() -> PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let pid = std::process::id(); + std::env::temp_dir().join(format!("boundline-artifact-test-{pid}-{n}")) + } + + fn sample_evidence_packet(run_id: &str) -> BrowserEvidencePacket { + BrowserEvidencePacket { + validation_run_id: run_id.into(), + provider_id: "test-provider".into(), + status: StepStatus::Completed, + started_at: "2026-06-24T00:00:00Z".into(), + completed_at: "2026-06-24T00:00:01Z".into(), + page_title: Some("Test Page".into()), + http_status: Some(200), + artifacts: vec![], + findings: vec![BrowserFinding { + kind: FindingKind::ConsoleError, + severity: FindingSeverity::Warning, + message: "test finding".into(), + evidence_refs: vec![], + retryability: None, + confirmed_intermittent: false, + }], + timing: StepTiming { + queue_wait_ms: None, + navigation_ms: Some(100), + readiness_wait_ms: None, + script_execution_ms: None, + accessibility_ms: None, + total_ms: 100, + }, + capabilities_active: vec!["screenshot".into()], + schema_version: 1, + } + } + + #[test] + fn new_creates_all_subdirectories() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + assert!(run_dir.join("screenshots").is_dir()); + assert!(run_dir.join("logs").is_dir()); + assert!(run_dir.join("dom").is_dir()); + assert!(run_dir.join("accessibility").is_dir()); + // Cleanup + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn write_screenshot_artifact() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let content = b"fake-png-data"; + let artifact = store + .write_artifact( + ArtifactKind::Screenshot, + "test.png", + content, + RetentionClass::RequiredEvidence, + "run-1", + ) + .expect("write ok"); + assert_eq!(artifact.kind, ArtifactKind::Screenshot); + assert_eq!(artifact.byte_size, 13); + assert_eq!(artifact.retention_class, RetentionClass::RequiredEvidence); + assert_eq!(artifact.media_type, "image/png"); + assert!(!artifact.content_hash.is_empty()); + assert!(artifact.relative_path.contains("screenshots/test.png")); + assert!(run_dir.join("screenshots/test.png").exists()); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn write_console_log_artifact() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::ConsoleLog, + "console.json", + b"[]", + RetentionClass::RequiredEvidence, + "run-2", + ) + .expect("write ok"); + assert_eq!(artifact.kind, ArtifactKind::ConsoleLog); + assert_eq!(artifact.media_type, "application/json"); + assert!(artifact.relative_path.contains("logs/console.json")); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn write_evidence_packet_produces_valid_json() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let packet = sample_evidence_packet("run-evidence"); + let artifact = store.write_evidence_packet(&packet, "run-evidence").expect("write ok"); + assert_eq!(artifact.kind, ArtifactKind::EvidencePacket); + assert!(run_dir.join("evidence.json").exists()); + // Verify it's valid JSON + let raw = std::fs::read_to_string(run_dir.join("evidence.json")).expect("read"); + let _parsed: serde_json::Value = serde_json::from_str(&raw).expect("valid json"); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn screenshot_over_size_limit_rejected() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + // Screenshot limit is 50 MB, so 51 MB should fail + let big = vec![0u8; 51 * 1024 * 1024]; + let err = store + .write_artifact( + ArtifactKind::Screenshot, + "big.png", + &big, + RetentionClass::RequiredEvidence, + "run-3", + ) + .expect_err("should reject"); + assert!(err.to_string().contains("exceeds size limit")); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn log_over_10mb_rejected() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let big = vec![0u8; 11 * 1024 * 1024]; + let err = store + .write_artifact( + ArtifactKind::ConsoleLog, + "big.log", + &big, + RetentionClass::RequiredEvidence, + "run-4", + ) + .expect_err("should reject"); + assert!(err.to_string().contains("exceeds size limit")); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn sha256_produces_stable_hash() { + let data = b"hello world"; + let h1 = sha256_hex(data); + let h2 = sha256_hex(data); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); // 32 bytes = 64 hex chars + } + + #[test] + fn artifact_content_hash_differs_for_different_data() { + let h1 = sha256_hex(b"a"); + let h2 = sha256_hex(b"b"); + assert_ne!(h1, h2); + } + + #[test] + fn run_dir_string_returns_valid_path() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + assert!(store.run_dir_string().is_some()); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn dom_snapshot_uses_text_html_media_type() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::DomSnapshot, + "page.html", + b"", + RetentionClass::Diagnostic, + "run-dom", + ) + .expect("write ok"); + assert_eq!(artifact.media_type, "text/html"); + assert_eq!(artifact.retention_class, RetentionClass::Diagnostic); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn accessibility_artifact_stored_in_accessibility_dir() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::AccessibilityOutput, + "a11y.json", + b"{}", + RetentionClass::RequiredEvidence, + "run-a11y", + ) + .expect("write ok"); + assert!(artifact.relative_path.contains("accessibility/a11y.json")); + assert_eq!(artifact.media_type, "application/json"); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn network_log_stored_in_logs_dir() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::NetworkLog, + "network.json", + b"[]", + RetentionClass::Verbose, + "run-net", + ) + .expect("write ok"); + assert!(artifact.relative_path.contains("logs/network.json")); + assert_eq!(artifact.retention_class, RetentionClass::Verbose); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn diff_image_uses_image_png_media_type() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::DiffImage, + "diff.png", + b"fake", + RetentionClass::RequiredEvidence, + "run-diff", + ) + .expect("write ok"); + assert_eq!(artifact.media_type, "image/png"); + assert!(artifact.relative_path.contains("screenshots/diff.png")); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn ephemeral_retention_class_preserved() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::ConsoleLog, + "temp.log", + b"ephemeral", + RetentionClass::Ephemeral, + "run-eph", + ) + .expect("write ok"); + assert_eq!(artifact.retention_class, RetentionClass::Ephemeral); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn evidence_packet_artifact_stored_in_root() { + let run_dir = temp_run_dir(); + let store = BrowserArtifactStore::new(&run_dir).expect("create store"); + let artifact = store + .write_artifact( + ArtifactKind::EvidencePacket, + "custom-evidence.json", + b"{}", + RetentionClass::RequiredEvidence, + "run-ep", + ) + .expect("write ok"); + // EvidencePacket goes in root, not a subdirectory + assert!(!artifact.relative_path.contains('/')); + assert_eq!(artifact.media_type, "application/json"); + let _ = std::fs::remove_dir_all(&run_dir); + let _ = store; + } + + #[test] + fn sha256_hex_is_64_chars_for_empty() { + assert_eq!(sha256_hex(b"").len(), 64); + } + + #[test] + fn sha256_deterministic_across_same_input() { + let data = vec![0u8; 1024]; + assert_eq!(sha256_hex(&data), sha256_hex(&data)); + } +} diff --git a/src/adapters/browser_provider_runtime.rs b/src/adapters/browser_provider_runtime.rs new file mode 100644 index 00000000..d5f9702e --- /dev/null +++ b/src/adapters/browser_provider_runtime.rs @@ -0,0 +1,219 @@ +//! Browser provider runtime adapter for Boundline. +//! +//! Spawns a registered browser capability provider as a subprocess, +//! writes JSON validation requests to stdin, reads JSON evidence +//! responses from stdout, and normalizes findings into Boundline +//! structured output. +//! +//! The browser provider is an external binary — Boundline does not +//! embed Playwright or any browser automation library. + +use boundline_core::domain::browser_provider::{BrowserEvidencePacket, BrowserValidationStep}; +use std::io::{BufRead, BufReader, Read, Write}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Errors that can occur during provider runtime operations. +#[derive(Debug, thiserror::Error)] +pub enum BrowserProviderError { + /// The provider binary could not be started. + #[error("failed to start browser provider `{command}`: {source}")] + StartFailure { command: String, source: std::io::Error }, + + /// The provider did not emit a startup handshake within the timeout. + #[error("browser provider startup handshake timed out after {timeout_seconds}s")] + HandshakeTimeout { timeout_seconds: u32 }, + + /// The provider emitted a malformed handshake line. + #[error("browser provider handshake malformed: {detail}")] + HandshakeMalformed { detail: String }, + + /// The provider exited unexpectedly before producing a response. + #[error("browser provider exited with code {code:?}: {stderr_summary}")] + ProviderExited { code: Option, stderr_summary: String }, + + /// The provider response was not valid JSON. + #[error("browser provider response was not valid JSON: {detail}")] + InvalidResponse { detail: String }, + + /// Writing the request to provider stdin failed. + #[error("failed to write request to browser provider stdin: {source}")] + RequestWriteFailure { source: std::io::Error }, + + /// Reading the response from provider stdout failed. + #[error("failed to read response from browser provider stdout: {source}")] + ResponseReadFailure { source: std::io::Error }, +} + +/// Manages the lifecycle of a browser capability provider subprocess. +pub struct BrowserProviderRuntime { + /// The provider child process, if currently running. + child: Option, + /// The configured command path. + command: String, + /// CLI arguments. + args: Vec, + /// Maximum seconds to wait for the startup handshake. + startup_timeout: Duration, +} + +impl BrowserProviderRuntime { + /// Create a new runtime handle for a browser provider. + pub fn new(command: &str, args: &[String], startup_timeout_seconds: u32) -> Self { + Self { + child: None, + command: command.to_string(), + args: args.to_vec(), + startup_timeout: Duration::from_secs(u64::from(startup_timeout_seconds)), + } + } + + /// Start the provider subprocess and read the startup handshake. + /// + /// # Errors + /// + /// Returns [`BrowserProviderError::StartFailure`] if the subprocess + /// cannot be spawned, [`BrowserProviderError::HandshakeTimeout`] if + /// no handshake line arrives within the configured timeout, or + /// [`BrowserProviderError::HandshakeMalformed`] if the handshake + /// JSON is invalid. + pub fn start(&mut self) -> Result<(), BrowserProviderError> { + let mut child = Command::new(&self.command) + .args(&self.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|source| BrowserProviderError::StartFailure { + command: self.command.clone(), + source, + })?; + + let stdout = child.stdout.take().ok_or_else(|| BrowserProviderError::StartFailure { + command: self.command.clone(), + source: std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "provider stdout unavailable", + ), + })?; + let mut reader = BufReader::new(stdout); + + let deadline = Instant::now() + self.startup_timeout; + let mut handshake_line = String::new(); + + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let _ = child.kill(); + return Err(BrowserProviderError::HandshakeTimeout { + timeout_seconds: self.startup_timeout.as_secs() as u32, + }); + } + + handshake_line.clear(); + let bytes = reader + .read_line(&mut handshake_line) + .map_err(|source| BrowserProviderError::ResponseReadFailure { source })?; + if bytes > 0 { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + let handshake: serde_json::Value = + serde_json::from_str(handshake_line.trim()).map_err(|_| { + BrowserProviderError::HandshakeMalformed { + detail: "handshake line is not valid JSON".into(), + } + })?; + + let protocol = handshake.get("protocol").and_then(|v| v.as_str()).unwrap_or(""); + if protocol != "browser-provider-v1" { + return Err(BrowserProviderError::HandshakeMalformed { + detail: format!("expected protocol 'browser-provider-v1', got '{protocol}'"), + }); + } + + self.child = Some(child); + Ok(()) + } + + /// Dispatch a validation step to the provider and collect the response. + /// + /// # Errors + /// + /// Returns a provider error if the request cannot be written, the + /// response cannot be read, the JSON is malformed, or the process + /// exits unexpectedly. + pub fn dispatch( + &mut self, + step: &BrowserValidationStep, + ) -> Result { + let child = self.child.as_mut().ok_or_else(|| BrowserProviderError::StartFailure { + command: self.command.clone(), + source: std::io::Error::new(std::io::ErrorKind::NotConnected, "provider not started"), + })?; + + let request_json = + serde_json::to_string(step).map_err(|e| BrowserProviderError::InvalidResponse { + detail: format!("request serialization failed: {e}"), + })?; + + { + let stdin = + child.stdin.as_mut().ok_or_else(|| BrowserProviderError::RequestWriteFailure { + source: std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "provider stdin unavailable", + ), + })?; + writeln!(stdin, "{request_json}") + .map_err(|source| BrowserProviderError::RequestWriteFailure { source })?; + stdin.flush().map_err(|source| BrowserProviderError::RequestWriteFailure { source })?; + } + + let stdout = + child.stdout.as_mut().ok_or_else(|| BrowserProviderError::ResponseReadFailure { + source: std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "provider stdout unavailable", + ), + })?; + let mut reader = BufReader::new(stdout); + let mut response_line = String::new(); + + reader + .read_line(&mut response_line) + .map_err(|source| BrowserProviderError::ResponseReadFailure { source })?; + + let packet: BrowserEvidencePacket = + serde_json::from_str(response_line.trim()).map_err(|e| { + BrowserProviderError::InvalidResponse { + detail: format!("response deserialization failed: {e}"), + } + })?; + + Ok(packet) + } + + /// Capture stderr output from the provider for diagnostics. + #[must_use] + pub fn capture_stderr(&mut self) -> Option { + let child = self.child.as_mut()?; + let stderr = child.stderr.take()?; + let mut reader = BufReader::new(stderr); + let mut stderr_bytes = Vec::new(); + let _ = reader.read_to_end(&mut stderr_bytes); + let output = String::from_utf8_lossy(&stderr_bytes).into_owned(); + if output.is_empty() { None } else { Some(output) } + } +} + +impl Drop for BrowserProviderRuntime { + fn drop(&mut self) { + if let Some(ref mut child) = self.child { + let _ = child.kill(); + let _ = child.wait(); + } + } +} diff --git a/src/cli/init.rs b/src/cli/init.rs index dbb1c1c5..2a635deb 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -5695,7 +5695,7 @@ mod tests { let workspace = temp_workspace("boundline-init-canon-blocked"); let mut blocked_status = blocked_canon_install_status( "Canon governance surface is unavailable", - "install or repair Canon 0.72.5 before rerunning init", + "install or repair Canon 0.72.6 before rerunning init", ); if let Some(surface) = blocked_status.surface_verification.as_mut() { surface.operations_verified = false; @@ -5745,7 +5745,7 @@ mod tests { report.terminal_output ); assert!( - report.terminal_output.contains("install or repair Canon 0.72.5 before rerunning init"), + report.terminal_output.contains("install or repair Canon 0.72.6 before rerunning init"), "{}", report.terminal_output ); @@ -5759,8 +5759,8 @@ mod tests { // must say "blocked before planning" and no workspace files must exist. let workspace = temp_workspace("boundline-init-canon-preflight"); let mut blocked_status = blocked_canon_install_status( - "Canon 0.10.0 is present but version 0.72.5 is required", - "upgrade Canon to 0.72.5 or later", + "Canon 0.10.0 is present but version 0.72.6 is required", + "upgrade Canon to 0.72.6 or later", ); if let Some(surface) = blocked_status.surface_verification.as_mut() { surface.version_compatible = false; @@ -5808,7 +5808,7 @@ mod tests { "expected 'blocked before planning' in output to confirm fail-fast path;\n{}", report.terminal_output ); - assert!(report.terminal_output.contains("0.72.5"), "{}", report.terminal_output); + assert!(report.terminal_output.contains("0.72.6"), "{}", report.terminal_output); // No workspace files must be created by the blocked run. assert!(!workspace.join(".boundline").exists()); } diff --git a/src/cli/inspect_browser.rs b/src/cli/inspect_browser.rs new file mode 100644 index 00000000..701db079 --- /dev/null +++ b/src/cli/inspect_browser.rs @@ -0,0 +1,77 @@ +//! Browser inspection CLI support for Boundline. +//! +//! Provides the programmatic surface for `boundline inspect browser`. +//! The clap subcommand wire-up is deferred to a follow-up PR. + +use boundline_core::domain::browser_provider::BrowserEvidencePacket; +use std::path::Path; + +/// Inspect a browser validation run by reading its evidence packet from +/// the session-scoped artifact directory. +/// +/// # Errors +/// +/// Returns an error message string if the evidence packet cannot be +/// read or parsed. +pub fn inspect_browser_run( + session_id: &str, + run_id: &str, + workspace_root: &Path, + show_artifacts: bool, + show_findings: bool, +) -> Result { + let evidence_path = workspace_root + .join(".boundline") + .join("sessions") + .join(session_id) + .join("browser") + .join(run_id) + .join("evidence.json"); + + let json = std::fs::read_to_string(&evidence_path).map_err(|e| { + format!("failed to read evidence packet at {}: {e}", evidence_path.display()) + })?; + + let packet: BrowserEvidencePacket = + serde_json::from_str(&json).map_err(|e| format!("invalid evidence packet: {e}"))?; + + let mut output = format!("Browser Validation Run: {}\n", packet.validation_run_id); + output.push_str(&format!(" provider: {}\n", packet.provider_id)); + output.push_str(&format!(" status: {:?}\n", packet.status)); + output.push_str(&format!( + " page: {} (HTTP {})\n", + packet.page_title.as_deref().unwrap_or("(none)"), + packet.http_status.map_or("(none)".into(), |s| s.to_string()) + )); + output.push_str(&format!(" started: {}\n", packet.started_at)); + output.push_str(&format!(" completed: {}\n", packet.completed_at)); + output.push_str(&format!(" duration: {}ms\n", packet.timing.total_ms)); + output.push_str(&format!(" artifacts: {} files\n", packet.artifacts.len())); + output.push_str(&format!(" findings: {}\n", packet.findings.len())); + output.push_str(&format!(" capabilities: {}\n", packet.capabilities_active.join(", "))); + + if show_artifacts && !packet.artifacts.is_empty() { + output.push_str("\nArtifacts:\n"); + for a in &packet.artifacts { + output.push_str(&format!( + " [{:?}] {} — {} bytes, hash={}, retention={:?}\n", + a.kind, a.relative_path, a.byte_size, a.content_hash, a.retention_class + )); + } + } + + if show_findings && !packet.findings.is_empty() { + output.push_str("\nFindings:\n"); + for f in &packet.findings { + output.push_str(&format!(" [{:?}] {:?}: {}\n", f.severity, f.kind, f.message)); + if let Some(ref hint) = f.retryability { + output.push_str(&format!( + " retryability: {:?}/{:?} — {}\n", + hint.level, hint.category, hint.reason + )); + } + } + } + + Ok(output) +} diff --git a/src/cli/validate_browser.rs b/src/cli/validate_browser.rs new file mode 100644 index 00000000..8da6b907 --- /dev/null +++ b/src/cli/validate_browser.rs @@ -0,0 +1,106 @@ +//! Browser validation CLI support for Boundline. +//! +//! Provides the programmatic dispatch surface for `boundline validate browser`. +//! The clap subcommand wire-up is deferred to a follow-up PR to avoid needing +//! changes across 30+ match arms in the top-level CLI dispatcher. + +use boundline_adapters::browser_artifact_store::BrowserArtifactStore; +use boundline_adapters::browser_provider_runtime::BrowserProviderRuntime; +use boundline_core::domain::browser_provider::{ + BrowserValidationStep, ReadinessLocator, ValidationTimeouts, +}; +use std::path::Path; +use uuid::Uuid; + +/// Parameters for a browser validation run. +pub struct ValidateBrowserParams<'a> { + pub provider_command: &'a str, + pub provider_args: &'a [String], + pub url: &'a str, + pub readiness_selector: Option<&'a str>, + pub readiness_state: Option<&'a str>, + pub readiness_timeout: Option, + pub session_id: &'a str, + pub workspace_root: &'a Path, +} + +/// Run a browser validation step against a target URL. +/// +/// Spawns the configured browser provider, dispatches the validation +/// step, writes artifacts to the session-scoped directory, and returns +/// the evidence packet. +/// +/// # Errors +/// +/// Returns an error message string suitable for terminal output if the +/// provider cannot be started, the handshake fails, or the dispatch fails. +pub fn run_validate_browser(params: &ValidateBrowserParams<'_>) -> Result { + let run_id = Uuid::new_v4().to_string(); + let artifact_dir = params + .workspace_root + .join(".boundline") + .join("sessions") + .join(params.session_id) + .join("browser") + .join(&run_id); + + let readiness = params.readiness_selector.map(|selector| { + let state = match params.readiness_state.unwrap_or("visible") { + "attached" => boundline_core::domain::browser_provider::LocatorState::Attached, + "hidden" => boundline_core::domain::browser_provider::LocatorState::Hidden, + "detached" => boundline_core::domain::browser_provider::LocatorState::Detached, + _ => boundline_core::domain::browser_provider::LocatorState::Visible, + }; + ReadinessLocator { + locator_type: boundline_core::domain::browser_provider::LocatorType::CssSelector, + value: selector.to_string(), + state, + timeout_seconds: params.readiness_timeout.unwrap_or(20), + stabilization_delay_ms: Some(250), + } + }); + + let step = BrowserValidationStep { + validation_run_id: run_id.clone(), + url: params.url.to_string(), + readiness, + interaction_script: None, + accessibility_enabled: false, + dom_inspection: None, + baseline_ref: None, + timeouts: ValidationTimeouts::default(), + network_allowlist: None, + artifact_dir: artifact_dir.to_string_lossy().to_string(), + session_id: params.session_id.to_string(), + }; + + let mut runtime = + BrowserProviderRuntime::new(params.provider_command, params.provider_args, 10); + runtime.start().map_err(|e| e.to_string())?; + + let packet = runtime.dispatch(&step).map_err(|e| e.to_string())?; + + let store = BrowserArtifactStore::new(&artifact_dir).map_err(|e| e.to_string())?; + let _evidence_ref = store.write_evidence_packet(&packet, &run_id).map_err(|e| e.to_string())?; + + let mut output = + format!("validation run {} completed with status {:?}\n", run_id, packet.status); + output + .push_str(&format!(" page title: {}\n", packet.page_title.as_deref().unwrap_or("(none)"))); + output.push_str(&format!( + " http status: {}\n", + packet.http_status.map_or("(none)".into(), |s| s.to_string()) + )); + output.push_str(&format!(" artifacts: {} files\n", packet.artifacts.len())); + output.push_str(&format!(" findings: {}\n", packet.findings.len())); + output.push_str(&format!(" duration: {}ms\n", packet.timing.total_ms)); + + for finding in &packet.findings { + output.push_str(&format!( + " [{:?}] {:?}: {}\n", + finding.severity, finding.kind, finding.message + )); + } + + Ok(output) +} diff --git a/src/domain/browser_provider.rs b/src/domain/browser_provider.rs new file mode 100644 index 00000000..c7a21a64 --- /dev/null +++ b/src/domain/browser_provider.rs @@ -0,0 +1,464 @@ +//! Browser capability provider domain model for Boundline. +//! +//! This module owns the domain types for browser validation steps, evidence +//! packets, findings, artifact references, interaction scripts, readiness +//! locators, and retryability hints. The browser provider communicates over +//! the existing external capability provider protocol (S10) via JSON over +//! stdio and produces session-scoped evidence packets with normalized +//! findings. +//! +//! Browser automation MUST NOT be embedded in the Boundline core runtime — +//! the provider is an external binary registered and activated through +//! configuration. + +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// StepStatus (T004) +// --------------------------------------------------------------------------- + +/// Primary outcome status of a browser validation step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepStatus { + Completed, + Failed, + TimedOut, + ProviderError, + Cancelled, + QueueTimeout, + QueueFull, +} + +// --------------------------------------------------------------------------- +// FindingSeverity + FindingKind (T005) +// --------------------------------------------------------------------------- + +/// Severity level of a browser validation finding. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingSeverity { + Blocking, + Warning, + Info, +} + +/// Category of a browser validation finding (12 variants). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindingKind { + ConsoleError, + AccessibilityViolation, + VisualDiffDetected, + NetworkAccessViolation, + PageLoadTimeout, + BrowserReadinessTimeout, + BaselineCreated, + ScriptStepFailed, + AccessibilityScanFailed, + BrowserConcurrencyTimeout, + BrowserQueueFull, + CancelledBeforeStart, +} + +// --------------------------------------------------------------------------- +// ArtifactKind + RetentionClass (T006) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ArtifactKind { + Screenshot, + ConsoleLog, + NetworkLog, + DomSnapshot, + AccessibilityOutput, + EvidencePacket, + DiffImage, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetentionClass { + RequiredEvidence, + Diagnostic, + Verbose, + Ephemeral, +} + +// --------------------------------------------------------------------------- +// ArtifactReference (T007) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactReference { + pub kind: ArtifactKind, + pub relative_path: String, + pub content_hash: String, + pub media_type: String, + pub byte_size: u64, + pub created_at: String, + pub retention_class: RetentionClass, + pub validation_run_id: String, +} + +// --------------------------------------------------------------------------- +// BrowserFinding (T008) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserFinding { + pub kind: FindingKind, + pub severity: FindingSeverity, + pub message: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub evidence_refs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retryability: Option, + #[serde(default)] + pub confirmed_intermittent: bool, +} + +// --------------------------------------------------------------------------- +// RetryabilityHint + enums (T009) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetryabilityLevel { + NotIndicated, + Possible, + Likely, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RetryabilityCategory { + NetworkTransient, + ResourceContention, + BrowserProcessFailure, + ProviderUnavailable, + QueueTimeout, + EnvironmentStartupDelay, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetryabilityHint { + pub level: RetryabilityLevel, + pub category: RetryabilityCategory, + pub reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timing_context: Option, +} + +// --------------------------------------------------------------------------- +// StepTiming (T010) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct StepTiming { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub navigation_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readiness_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script_execution_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accessibility_ms: Option, + pub total_ms: u64, +} + +// --------------------------------------------------------------------------- +// LocatorType + LocatorState (T011) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LocatorType { + CssSelector, + TestId, + AccessibleRole, + Text, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LocatorState { + Attached, + Visible, + Hidden, + Detached, +} + +// --------------------------------------------------------------------------- +// ReadinessLocator (T012) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReadinessLocator { + #[serde(rename = "type")] + pub locator_type: LocatorType, + pub value: String, + pub state: LocatorState, + pub timeout_seconds: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stabilization_delay_ms: Option, +} + +// --------------------------------------------------------------------------- +// BrowserAction (T013) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum BrowserAction { + Navigate { + url: String, + #[serde(default = "default_script_step_timeout")] + timeout_seconds: u32, + }, + Click { + selector: String, + #[serde(default = "default_script_step_timeout")] + timeout_seconds: u32, + }, + Type { + selector: String, + text: String, + #[serde(default = "default_script_step_timeout")] + timeout_seconds: u32, + }, + Wait { + selector_or_ms: String, + }, + Screenshot { + label: String, + }, +} + +const fn default_script_step_timeout() -> u32 { + 10 +} + +// --------------------------------------------------------------------------- +// BrowserEvidencePacket (T014) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserEvidencePacket { + pub validation_run_id: String, + pub provider_id: String, + pub status: StepStatus, + pub started_at: String, + pub completed_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page_title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http_status: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub findings: Vec, + pub timing: StepTiming, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub capabilities_active: Vec, + pub schema_version: u32, +} + +// --------------------------------------------------------------------------- +// DomInspectionConfig + BrowserValidationStep (T015) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DomInspectionConfig { + #[serde(default = "default_dom_root_selector")] + pub root_selector: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_depth: Option, +} + +fn default_dom_root_selector() -> String { + "body".into() +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserValidationStep { + pub validation_run_id: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub readiness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interaction_script: Option>, + #[serde(default)] + pub accessibility_enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dom_inspection: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub baseline_ref: Option, + #[serde(default)] + pub timeouts: ValidationTimeouts, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub network_allowlist: Option>, + pub artifact_dir: String, + pub session_id: String, +} + +// --------------------------------------------------------------------------- +// ValidationTimeouts (T016) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ValidationTimeouts { + #[serde(default = "default_page_load_timeout")] + pub page_load_seconds: u32, + #[serde(default = "default_readiness_timeout")] + pub readiness_seconds: u32, + #[serde(default = "default_script_step_timeout")] + pub script_step_seconds: u32, + #[serde(default = "default_execution_timeout")] + pub execution_seconds: u32, +} + +const fn default_page_load_timeout() -> u32 { + 30 +} +const fn default_readiness_timeout() -> u32 { + 20 +} +const fn default_execution_timeout() -> u32 { + 120 +} + +impl Default for ValidationTimeouts { + fn default() -> Self { + Self { + page_load_seconds: default_page_load_timeout(), + readiness_seconds: default_readiness_timeout(), + script_step_seconds: default_script_step_timeout(), + execution_seconds: default_execution_timeout(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_status_all_variants_serialize() { + let variants = [ + StepStatus::Completed, + StepStatus::Failed, + StepStatus::TimedOut, + StepStatus::ProviderError, + StepStatus::Cancelled, + StepStatus::QueueTimeout, + StepStatus::QueueFull, + ]; + for v in &variants { + let json = serde_json::to_string(v).expect("serialize"); + let round: StepStatus = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(*v, round, "StepStatus round-trip failed for {v:?}"); + } + } + + #[test] + fn finding_kind_all_variants_serialize() { + let variants = [ + FindingKind::ConsoleError, + FindingKind::AccessibilityViolation, + FindingKind::VisualDiffDetected, + FindingKind::NetworkAccessViolation, + FindingKind::PageLoadTimeout, + FindingKind::BrowserReadinessTimeout, + FindingKind::BaselineCreated, + FindingKind::ScriptStepFailed, + FindingKind::AccessibilityScanFailed, + FindingKind::BrowserConcurrencyTimeout, + FindingKind::BrowserQueueFull, + FindingKind::CancelledBeforeStart, + ]; + for v in &variants { + let json = serde_json::to_string(v).expect("serialize"); + let round: FindingKind = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(*v, round, "FindingKind round-trip failed for {v:?}"); + } + } + + #[test] + fn evidence_packet_round_trip() { + let packet = BrowserEvidencePacket { + validation_run_id: "run-1".into(), + provider_id: "browser-playwright".into(), + status: StepStatus::Completed, + started_at: "2026-06-20T10:00:00Z".into(), + completed_at: "2026-06-20T10:00:05Z".into(), + page_title: Some("Test Page".into()), + http_status: Some(200), + artifacts: vec![ArtifactReference { + kind: ArtifactKind::Screenshot, + relative_path: "screenshots/final.png".into(), + content_hash: "sha256:abc123".into(), + media_type: "image/png".into(), + byte_size: 12345, + created_at: "2026-06-20T10:00:05Z".into(), + retention_class: RetentionClass::RequiredEvidence, + validation_run_id: "run-1".into(), + }], + findings: vec![], + timing: StepTiming { + queue_wait_ms: None, + navigation_ms: Some(1840), + readiness_wait_ms: Some(350), + script_execution_ms: None, + accessibility_ms: None, + total_ms: 2190, + }, + capabilities_active: vec!["screenshot".into(), "console".into()], + schema_version: 1, + }; + let json = serde_json::to_string(&packet).expect("serialize"); + let round: BrowserEvidencePacket = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(packet.validation_run_id, round.validation_run_id); + assert_eq!(packet.status, round.status); + assert_eq!(packet.schema_version, round.schema_version); + assert_eq!(packet.artifacts.len(), 1); + assert_eq!(packet.artifacts[0].kind, ArtifactKind::Screenshot); + } + + #[test] + fn validation_step_serialization_matches_contract() { + let step = BrowserValidationStep { + validation_run_id: "run-2".into(), + url: "http://localhost:3000".into(), + readiness: Some(ReadinessLocator { + locator_type: LocatorType::TestId, + value: "dashboard-ready".into(), + state: LocatorState::Visible, + timeout_seconds: 20, + stabilization_delay_ms: Some(250), + }), + interaction_script: None, + accessibility_enabled: false, + dom_inspection: None, + baseline_ref: None, + timeouts: ValidationTimeouts::default(), + network_allowlist: None, + artifact_dir: ".boundline/sessions/sess-1/browser/run-2".into(), + session_id: "sess-1".into(), + }; + let json = serde_json::to_string_pretty(&step).expect("serialize"); + // Verify key fields present in contract format + assert!(json.contains("\"validation_run_id\": \"run-2\"")); + assert!(json.contains("\"url\": \"http://localhost:3000\"")); + assert!(json.contains("\"type\": \"test_id\"")); + // readiness should have "type" not "locator_type" per contract + let value: serde_json::Value = serde_json::from_str(&json).expect("parse"); + let readiness = value.get("readiness").expect("readiness field"); + assert_eq!(readiness.get("type").and_then(|v| v.as_str()), Some("test_id")); + assert_eq!(readiness.get("state").and_then(|v| v.as_str()), Some("visible")); + } +} diff --git a/src/domain/capability_provider.rs b/src/domain/capability_provider.rs index f7897897..9a1f5981 100644 --- a/src/domain/capability_provider.rs +++ b/src/domain/capability_provider.rs @@ -579,9 +579,69 @@ pub struct CapabilityProviderProjection { pub summary: String, } +// --------------------------------------------------------------------------- +// Browser capability provider +// --------------------------------------------------------------------------- + +/// Stable protocol line for the browser capability provider contract. +pub const BROWSER_PROVIDER_PROTOCOL_LINE_V1: &str = "browser-provider-v1"; + +/// Configuration for a registered browser capability provider. +/// +/// Boundline uses this to spawn and communicate with an external +/// browser-automation binary over JSON stdio. Browser automation is a +/// provider capability, not core Boundline runtime. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserProviderConfig { + /// Stable provider identifier. + pub provider_id: String, + /// Transport used to contact the provider (must be `"command"` for V1). + pub command: String, + /// CLI arguments passed to the provider binary. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Optional working directory override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_dir: Option, + /// Environment variables inherited by the provider process (allowlist). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub env_inherit: Vec, + /// Maximum seconds to wait for the provider startup handshake. + pub startup_timeout_seconds: u32, + /// Whether the provider is enabled for execution. + pub enabled: bool, + /// Maximum concurrent browser instances the provider may manage. + pub max_concurrency: u32, + /// Maximum requests allowed in the provider's internal queue. + pub max_queue_size: u32, + /// Maximum seconds a request may wait in the queue. + pub queue_timeout_seconds: u32, + /// Maximum seconds for a single validation step execution. + pub execution_timeout_seconds: u32, +} + +impl Default for BrowserProviderConfig { + fn default() -> Self { + Self { + provider_id: String::new(), + command: String::new(), + args: Vec::new(), + working_dir: None, + env_inherit: vec!["PATH".into()], + startup_timeout_seconds: 10, + enabled: false, + max_concurrency: 1, + max_queue_size: 5, + queue_timeout_seconds: 60, + execution_timeout_seconds: 120, + } + } +} + #[cfg(test)] mod tests { use super::{ + BROWSER_PROVIDER_PROTOCOL_LINE_V1, BrowserProviderConfig, CAPABILITY_PROVIDER_PROTOCOL_LINE_V1, CapabilityProviderActivationState, CapabilityProviderDiscoveryState, CapabilityProviderRegistration, CapabilityProviderRegistrationSource, CapabilityProviderTransportKind, @@ -682,4 +742,43 @@ mod tests { CapabilityProviderTransportKind::Http ); } + + #[test] + fn browser_provider_config_defaults_are_sensible() { + let cfg = BrowserProviderConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.max_concurrency, 1); + assert_eq!(cfg.max_queue_size, 5); + assert_eq!(cfg.queue_timeout_seconds, 60); + assert_eq!(cfg.execution_timeout_seconds, 120); + assert_eq!(cfg.startup_timeout_seconds, 10); + assert!(cfg.env_inherit.contains(&"PATH".to_string())); + } + + #[test] + fn browser_provider_config_serialization_round_trip() { + let cfg = BrowserProviderConfig { + provider_id: "browser-playwright".into(), + command: "boundline-browser-provider".into(), + args: vec!["serve".into(), "--stdio".into()], + working_dir: None, + env_inherit: vec!["PATH".into(), "DISPLAY".into()], + startup_timeout_seconds: 15, + enabled: true, + max_concurrency: 2, + max_queue_size: 10, + queue_timeout_seconds: 30, + execution_timeout_seconds: 60, + }; + let json = serde_json::to_string(&cfg).expect("serialize"); + let parsed: BrowserProviderConfig = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(parsed.provider_id, cfg.provider_id); + assert_eq!(parsed.max_concurrency, 2); + assert!(parsed.enabled); + } + + #[test] + fn browser_protocol_line_is_stable() { + assert_eq!(BROWSER_PROVIDER_PROTOCOL_LINE_V1, "browser-provider-v1"); + } } diff --git a/src/domain/distribution.rs b/src/domain/distribution.rs index 995ff7d8..face03a4 100644 --- a/src/domain/distribution.rs +++ b/src/domain/distribution.rs @@ -8,7 +8,7 @@ use crate::domain::governance::{CANONICAL_MODES, CanonCapabilitySnapshot, CanonM // Keep the supported Canon companion target centralized so release metadata, // docs, and compatibility checks advance together. -pub const SUPPORTED_CANON_VERSION: &str = "0.72.5"; +pub const SUPPORTED_CANON_VERSION: &str = "0.72.6"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/src/domain/observability.rs b/src/domain/observability.rs index 691501f1..3a229aac 100644 --- a/src/domain/observability.rs +++ b/src/domain/observability.rs @@ -39,10 +39,11 @@ pub enum EventType { ControlDegraded, ControlEscalated, RefinementRoundCompleted, + BrowserValidationCompleted, } impl EventType { - pub const fn all() -> [Self; 15] { + pub const fn all() -> [Self; 16] { [ Self::PlanningAnalysisCompleted, Self::GuardianFindingEmitted, @@ -59,6 +60,7 @@ impl EventType { Self::ControlDegraded, Self::ControlEscalated, Self::RefinementRoundCompleted, + Self::BrowserValidationCompleted, ] } @@ -79,6 +81,7 @@ impl EventType { Self::ControlDegraded => "control.degraded", Self::ControlEscalated => "control.escalated", Self::RefinementRoundCompleted => "refinement.round.completed", + Self::BrowserValidationCompleted => "browser.validation.completed", } } @@ -99,6 +102,7 @@ impl EventType { Self::ControlDegraded => SCHEMA_VERSION_COUNCIL_DECISION, Self::ControlEscalated => SCHEMA_VERSION_COUNCIL_DECISION, Self::RefinementRoundCompleted => SCHEMA_VERSION_REFINEMENT_ROUND, + Self::BrowserValidationCompleted => "1.0", } } } diff --git a/tests/contract/canon_reasoning_posture_contract.rs b/tests/contract/canon_reasoning_posture_contract.rs index 5368fe9f..d79e59e1 100644 --- a/tests/contract/canon_reasoning_posture_contract.rs +++ b/tests/contract/canon_reasoning_posture_contract.rs @@ -15,9 +15,9 @@ const VERSION_ALIGNMENT_BRIEF_PATH: &str = concat!( env!("CARGO_MANIFEST_DIR"), "/specs/061-reasoning-profile-contracts/contracts/reasoning-version-alignment-contract.md" ); -const SUPPORTED_BOUNDLINE_VERSION: &str = "0.81.0"; +const SUPPORTED_BOUNDLINE_VERSION: &str = "0.82.0"; const SUPPORTED_BOUNDLINE_WINDOW: &str = "0.79.x"; -const SUPPORTED_CANON_VERSION: &str = "0.72.5"; +const SUPPORTED_CANON_VERSION: &str = "0.72.6"; const SUPPORTED_CANON_WINDOW: &str = "0.71.x"; const SUPPORTED_CONTRACT_LINE: &str = "governed_reasoning_posture_v1"; diff --git a/tests/fixtures/canon_capabilities_full.json b/tests/fixtures/canon_capabilities_full.json index 62127db6..96562300 100644 --- a/tests/fixtures/canon_capabilities_full.json +++ b/tests/fixtures/canon_capabilities_full.json @@ -1 +1 @@ -{"canon_version":"0.72.5","supported_schema_versions":["2026-06-11"],"operations":["start","refresh","capabilities"],"supported_modes":["requirements","discovery","system-shaping","architecture","backlog","change","implementation","refactor","review","verification","pr-review","incident","security-assessment","system-assessment","migration","supply-chain-analysis","brainstorming","debugging","policy-shaping"],"status_values":["governed_ready"],"approval_state_values":["not_needed"],"packet_readiness_values":["reusable"],"compatibility_notes":["stable-json"]} +{"canon_version":"0.72.6","supported_schema_versions":["2026-06-11"],"operations":["start","refresh","capabilities"],"supported_modes":["requirements","discovery","system-shaping","architecture","backlog","change","implementation","refactor","review","verification","pr-review","incident","security-assessment","system-assessment","migration","supply-chain-analysis","brainstorming","debugging","policy-shaping"],"status_values":["governed_ready"],"approval_state_values":["not_needed"],"packet_readiness_values":["reusable"],"compatibility_notes":["stable-json"]} diff --git a/tests/fixtures/canon_capabilities_missing_mode.json b/tests/fixtures/canon_capabilities_missing_mode.json index d631569d..4c01081f 100644 --- a/tests/fixtures/canon_capabilities_missing_mode.json +++ b/tests/fixtures/canon_capabilities_missing_mode.json @@ -1 +1 @@ -{"canon_version":"0.72.5","supported_schema_versions":["2026-06-11"],"operations":["start","refresh","capabilities"],"supported_modes":["requirements","discovery","system-shaping","architecture","backlog","change","implementation","refactor","review","verification","pr-review","incident","security-assessment","system-assessment","migration","brainstorming","debugging","policy-shaping"],"status_values":["governed_ready"],"approval_state_values":["not_needed"],"packet_readiness_values":["reusable"],"compatibility_notes":["stable-json"]} +{"canon_version":"0.72.6","supported_schema_versions":["2026-06-11"],"operations":["start","refresh","capabilities"],"supported_modes":["requirements","discovery","system-shaping","architecture","backlog","change","implementation","refactor","review","verification","pr-review","incident","security-assessment","system-assessment","migration","brainstorming","debugging","policy-shaping"],"status_values":["governed_ready"],"approval_state_values":["not_needed"],"packet_readiness_values":["reusable"],"compatibility_notes":["stable-json"]} diff --git a/tests/fixtures/canon_capabilities_missing_operation.json b/tests/fixtures/canon_capabilities_missing_operation.json index 42129f0c..83ea7a28 100644 --- a/tests/fixtures/canon_capabilities_missing_operation.json +++ b/tests/fixtures/canon_capabilities_missing_operation.json @@ -1 +1 @@ -{"canon_version":"0.72.5","supported_schema_versions":["2026-06-11"],"operations":["capabilities"],"supported_modes":["requirements","discovery","system-shaping","architecture","backlog","change","implementation","refactor","review","verification","pr-review","incident","security-assessment","system-assessment","migration","supply-chain-analysis","brainstorming","debugging","policy-shaping"],"status_values":["governed_ready"],"approval_state_values":["not_needed"],"packet_readiness_values":["reusable"],"compatibility_notes":["stable-json"]} +{"canon_version":"0.72.6","supported_schema_versions":["2026-06-11"],"operations":["capabilities"],"supported_modes":["requirements","discovery","system-shaping","architecture","backlog","change","implementation","refactor","review","verification","pr-review","incident","security-assessment","system-assessment","migration","supply-chain-analysis","brainstorming","debugging","policy-shaping"],"status_values":["governed_ready"],"approval_state_values":["not_needed"],"packet_readiness_values":["reusable"],"compatibility_notes":["stable-json"]} diff --git a/tests/unit/capability_provider_transport_runtime.rs b/tests/unit/capability_provider_transport_runtime.rs index a3190412..bde8fe45 100644 --- a/tests/unit/capability_provider_transport_runtime.rs +++ b/tests/unit/capability_provider_transport_runtime.rs @@ -53,7 +53,9 @@ fn command_transport_uses_working_directory_when_configured() -> Result<(), Box< assert_eq!(health.checked_at, 7); let recorded = fs::read_to_string(output_path)?; - assert_eq!(Path::new(recorded.trim()), fs::canonicalize(&working_directory)?.as_path()); + let recorded_canonical = fs::canonicalize(Path::new(recorded.trim()))?; + let expected_canonical = fs::canonicalize(&working_directory)?; + assert_eq!(recorded_canonical, expected_canonical); Ok(()) } diff --git a/tests/unit/distribution_metadata.rs b/tests/unit/distribution_metadata.rs index ba16355e..db68b22c 100644 --- a/tests/unit/distribution_metadata.rs +++ b/tests/unit/distribution_metadata.rs @@ -5,5 +5,5 @@ fn supported_distribution_channels_always_include_source_fallback() { let channels = supported_distribution_channels(); assert!(channels.contains(&DistributionChannel::Source)); - assert_eq!(SUPPORTED_CANON_VERSION, "0.72.5"); + assert_eq!(SUPPORTED_CANON_VERSION, "0.72.6"); }