Compare commits

...

26 commits
v0.0.2 ... dev

Author SHA1 Message Date
6adaeaeec2 Merge pull request '[agent/codex:gpt-5.3-codex-spark] Read ~/spec/code/core/go/webview/RFC.md fully. Find features...' (#14) from agent/read---spec-code-core-go-webview-rfc-md into dev
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
2026-04-03 07:37:57 +00:00
Virgil
e6a7ecf4f5 chore(webview): align console buffers with RFC defaults
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 07:37:51 +00:00
Virgil
b993fc18f3 feat(webview): populate element content fields
Some checks failed
Test / test (push) Successful in 55s
Security Scan / security (push) Failing after 12m32s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 04:47:46 +00:00
e35de439e1 Merge pull request '[agent/codex] A specs/RFC.md stub has been injected. Read the actual sourc...' (#12) from agent/a-specs-rfc-md-stub-has-been-injected--r into dev
All checks were successful
Security Scan / security (push) Successful in 18s
Test / test (push) Successful in 33s
2026-03-27 20:52:20 +00:00
Virgil
950b02fd97 docs: document exported API in specs RFC
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-27 20:52:02 +00:00
Claude
96f18346d9
feat: upgrade to core v0.8.0-alpha.1, replace banned stdlib imports
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 1m15s
Replace fmt, errors, strings, encoding/json with Core primitives.
Keep strings.EqualFold, json.NewEncoder (no core equivalents).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:53:43 +00:00
ab48e6c373 Merge pull request '[agent/codex] API contract extraction. For every exported type, function, ...' (#10) from agent/api-contract-extraction--for-every-expor into dev
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 37s
Security Scan / security (pull_request) Successful in 8s
Test / test (pull_request) Successful in 39s
2026-03-23 15:27:24 +00:00
Virgil
df0d10b880 docs(api): add exported contract matrix
Add a markdown inventory of every exported type, function, and method with its current signature, a concise description, and test coverage notes based on webview_test.go.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-23 15:27:05 +00:00
674864217e Merge pull request '[agent/codex] Convention drift check. Read CLAUDE.md. stdlib→core.*, UK ...' (#9) from agent/convention-drift-check--read-claude-md into dev
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 36s
2026-03-23 15:24:08 +00:00
Virgil
dce6f0e788 docs: add convention drift audit
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-23 15:23:43 +00:00
4004fe4484 Merge pull request '[agent/codex] Security attack vector mapping. Read CLAUDE.md. Map every ex...' (#6) from agent/security-attack-vector-mapping--read-cla into dev
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 40s
2026-03-23 13:39:56 +00:00
Virgil
6a261bdf16 docs(cdp): add attack vector mapping
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-23 13:39:39 +00:00
2725661046 Merge pull request '[agent/codex] Fix ALL findings from issue #2. Read CLAUDE.md first. CDPCli...' (#3) from agent/deep-audit-per-issue--2--read-claude-md into dev
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 40s
2026-03-23 07:34:46 +00:00
Virgil
dff3d576fa fix(cdp): resolve issue 2 audit findings
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-23 07:34:16 +00:00
Claude
c6d1ccba7d
chore: update dappco.re/go/core/log to v0.1.0
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 01:06:33 +00:00
Claude
2f9ff11204
chore: migrate to dappco.re vanity import path
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 42s
Change module path from forge.lthn.ai/core/go-webview to
dappco.re/go/core/webview. Update all Go imports and documentation
references. The go-log dependency uses a replace directive to
resolve via the forge until the vanity redirect is configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:45:30 +00:00
e677d877eb Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#1) from agent/dx-audit-and-fix--1--review-claude-md into main
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 38s
2026-03-17 08:54:39 +00:00
Snider
900cb750cf fix(console): buffer trim panic when limit < 100, add unit tests
All checks were successful
Security Scan / security (pull_request) Successful in 10s
Test / test (pull_request) Successful in 30s
CLAUDE.md: update error wrapping guidance to reflect coreerr.E() convention.

Console buffer trimming in both Webview.addConsoleMessage and
ConsoleWatcher.addMessage panicked with slice bounds out of range
when consoleLimit was smaller than 100. Use min(100, len) for safe
batch trimming.

Added 22 unit tests covering pure functions (FormatConsoleOutput,
containsString, findString, formatJSValue, getString), ConsoleWatcher
filter/count/handler logic, ExceptionWatcher operations, WaitAction
context handling, and buffer limit enforcement. Coverage: 3.2% → 16.1%.

DX audit findings:
- Error handling: clean (all coreerr.E(), no fmt.Errorf)
- File I/O: clean (no os.ReadFile/os.WriteFile — package uses HTTP/WS only)

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 08:54:22 +00:00
Snider
ab77e922bd refactor: replace fmt.Errorf/errors.New with coreerr.E()
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 1m14s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 21:10:49 +00:00
Snider
978e153615 chore: add .core/ and .idea/ to .gitignore
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 38s
2026-03-15 10:17:50 +00:00
Snider
93513e25e6 feat: export TargetInfo type for external CDP target enumeration
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:00:31 +00:00
Snider
4169c97b71 docs: add CLAUDE.md project instructions
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 33s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-13 13:38:02 +00:00
Snider
29ebe46fe7 docs: add human-friendly documentation
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 34s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:02:40 +00:00
Snider
6a459ec08b chore: add .core/ build and release configs
All checks were successful
Security Scan / security (push) Successful in 5s
Test / test (push) Successful in 33s
Add go-devops build system configuration for standardised
build, test, and release workflows across the Go ecosystem.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 18:52:37 +00:00
Snider
ebdfb38e35 chore: remove boilerplate Taskfile
All checks were successful
Security Scan / security (push) Successful in 7s
Test / test (push) Successful in 1m15s
All tasks (test, build, lint, fmt, vet, cov) are handled natively
by `core go` commands. Taskfile was redundant wrapper.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 14:45:50 +00:00
Snider
37d22bcaa1 chore: add Go repo norms (badges, contributing, lint, taskfile, editorconfig)
All checks were successful
Security Scan / security (push) Successful in 15s
Test / test (push) Successful in 49s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-23 06:45:50 +00:00
25 changed files with 3182 additions and 543 deletions

24
.core/build.yaml Normal file
View file

@ -0,0 +1,24 @@
version: 1
project:
name: go-webview
description: Chrome DevTools Protocol client
binary: ""
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: arm64
- os: windows
arch: amd64

20
.core/release.yaml Normal file
View file

@ -0,0 +1,20 @@
version: 1
project:
name: go-webview
repository: core/go-webview
publishers: []
changelog:
include:
- feat
- fix
- perf
- refactor
exclude:
- chore
- docs
- style
- test
- ci

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
[*.{md,yml,yaml,json,txt}]
indent_style = space
indent_size = 2

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.core/
.idea/

22
.golangci.yml Normal file
View file

@ -0,0 +1,22 @@
run:
timeout: 5m
go: "1.26"
linters:
enable:
- govet
- errcheck
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- gocritic
- gofmt
disable:
- exhaustive
- wrapcheck
issues:
exclude-use-default: false
max-same-issues: 0

View file

@ -1,33 +1,63 @@
# CLAUDE.md # CLAUDE.md
Module: `forge.lthn.ai/core/go-webview` — Chrome DevTools Protocol client for browser automation. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Module: `dappco.re/go/core/webview` — Chrome DevTools Protocol client for browser automation.
## Commands ## Commands
```bash ```bash
go test ./... # Run all tests (must pass before commit) go test ./... # Run all tests (must pass before commit)
go test -v -run Name # Run a single test go test -v -run TestActionSequence_Good ./... # Run a single test
gofmt -w . # Format code gofmt -w . # Format code
go vet ./... # Static analysis
``` ```
## Prerequisites
Tests and usage require a running Chrome/Chromium with `--remote-debugging-port=9222`. The package does not launch Chrome itself. Verify with `curl http://localhost:9222/json`.
```bash
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
# Headless (CI)
google-chrome --headless=new --remote-debugging-port=9222 --no-sandbox --disable-gpu
```
## Architecture
```
Application Code → Webview (high-level API) → CDPClient (WebSocket transport) → Chrome
```
Single flat package (`package webview`). No sub-packages.
| File | Layer | Purpose |
|------|-------|---------|
| `cdp.go` | Transport | WebSocket connection, CDP message framing, event dispatch. No browser-level logic. |
| `webview.go` | High-level API | Navigate, Click, Type, Screenshot, DOM queries, console capture. Application-facing. |
| `actions.go` | Action system | `Action` interface + concrete types (ClickAction, TypeAction, etc.) + fluent `ActionSequence` builder. |
| `console.go` | Diagnostics | `ConsoleWatcher` (filtered console capture) and `ExceptionWatcher` (JS exception tracking). |
| `angular.go` | SPA helpers | Angular-specific: Zone.js stability, router navigation, component introspection, ngModel access. |
Key patterns:
- `CDPClient.Call(ctx, method, params)` sends a CDP command and blocks for the response via a pending-channel map.
- Events flow from Chrome → `readLoop` goroutine → registered handlers (each dispatched in its own goroutine).
- `Webview` enables `Runtime`, `Page`, and `DOM` CDP domains on construction; console capture starts immediately.
- New action types: add struct + `Execute` method in `actions.go`, builder method on `ActionSequence`, `_Good` test.
- New SPA framework helpers: create `<framework>.go` following the `angular.go` pattern.
## Coding Standards ## Coding Standards
- UK English in all comments, docs, and commit messages - UK English in all comments, docs, and commit messages (behaviour, colour, initialise)
- EUPL-1.2 licence header (`// SPDX-License-Identifier: EUPL-1.2`) in every Go file - EUPL-1.2 licence header (`// SPDX-License-Identifier: EUPL-1.2`) in every Go file
- Conventional commits: `type(scope): description` - Conventional commits: `type(scope): description` (scopes: cdp, angular, console, actions)
- Co-author trailer on every commit: `Co-Authored-By: Virgil <virgil@lethean.io>` - Co-author trailer on every commit: `Co-Authored-By: Virgil <virgil@lethean.io>`
- Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases) - Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases)
- Standard `testing.T` only — no test frameworks
## Key API - Wrap errors with `coreerr.E("Scope.Method", "description", err)` from `go-log`, never `fmt.Errorf`
- Protect shared state with `sync.RWMutex`; copy handler slices before calling outside lock
```go
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
defer wv.Close()
wv.Navigate("https://example.com")
wv.Click("#submit")
wv.Type("#input", "text")
screenshot, _ := wv.Screenshot()
```
## Docs ## Docs

35
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,35 @@
# Contributing
Thank you for your interest in contributing!
## Requirements
- **Go Version**: 1.26 or higher is required.
- **Tools**: `golangci-lint` and `task` (Taskfile.dev) are recommended.
## Development Workflow
1. **Testing**: Ensure all tests pass before submitting changes.
```bash
go test ./...
```
2. **Code Style**: All code must follow standard Go formatting.
```bash
gofmt -w .
go vet ./...
```
3. **Linting**: We use `golangci-lint` to maintain code quality.
```bash
golangci-lint run ./...
```
## Commit Message Format
We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
- `feat`: A new feature
- `fix`: A bug fix
- `docs`: Documentation changes
- `refactor`: A code change that neither fixes a bug nor adds a feature
- `chore`: Changes to the build process or auxiliary tools and libraries
Example: `feat: add new endpoint for health check`
## License
By contributing to this project, you agree that your contributions will be licensed under the **European Union Public Licence (EUPL-1.2)**.

View file

@ -1,15 +1,19 @@
[![Go Reference](https://pkg.go.dev/badge/dappco.re/go/core/webview.svg)](https://pkg.go.dev/dappco.re/go/core/webview)
[![License: EUPL-1.2](https://img.shields.io/badge/License-EUPL--1.2-blue.svg)](LICENSE.md)
[![Go Version](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat&logo=go)](go.mod)
# go-webview # go-webview
Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping. Connects to an externally managed Chrome or Chromium instance running with `--remote-debugging-port=9222`, providing navigation, DOM queries, click and type actions, console capture, JavaScript evaluation, screenshots, multi-tab support, viewport emulation, and a fluent `ActionSequence` builder. Includes Angular-specific helpers for Zone.js stability, router navigation, component introspection, and ngModel access. Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping. Connects to an externally managed Chrome or Chromium instance running with `--remote-debugging-port=9222`, providing navigation, DOM queries, click and type actions, console capture, JavaScript evaluation, screenshots, multi-tab support, viewport emulation, and a fluent `ActionSequence` builder. Includes Angular-specific helpers for Zone.js stability, router navigation, component introspection, and ngModel access.
**Module**: `forge.lthn.ai/core/go-webview` **Module**: `dappco.re/go/core/webview`
**Licence**: EUPL-1.2 **Licence**: EUPL-1.2
**Language**: Go 1.25 **Language**: Go 1.25
## Quick Start ## Quick Start
```go ```go
import "forge.lthn.ai/core/go-webview" import "dappco.re/go/core/webview"
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222")) wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
defer wv.Close() defer wv.Close()
@ -30,6 +34,7 @@ err = webview.NewActionSequence().
## Documentation ## Documentation
- [API Contract](docs/api-contract.md) — exported API inventory with signatures and current test coverage
- [Architecture](docs/architecture.md) — CDP connection, DOM queries, console capture, Angular helpers, action system - [Architecture](docs/architecture.md) — CDP connection, DOM queries, console capture, Angular helpers, action system
- [Development Guide](docs/development.md) — prerequisites, build, test patterns, adding actions - [Development Guide](docs/development.md) — prerequisites, build, test patterns, adding actions
- [Project History](docs/history.md) — completed phases, known limitations, future considerations - [Project History](docs/history.md) — completed phases, known limitations, future considerations

View file

@ -1,9 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"fmt"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// Action represents a browser action that can be performed. // Action represents a browser action that can be performed.
@ -43,7 +46,7 @@ func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error {
"url": a.URL, "url": a.URL,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to navigate: %w", err) return coreerr.E("NavigateAction.Execute", "failed to navigate", err)
} }
return wv.waitForLoad(ctx) return wv.waitForLoad(ctx)
} }
@ -81,7 +84,7 @@ type ScrollAction struct {
// Execute performs the scroll action. // Execute performs the scroll action.
func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error { func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y) script := core.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -93,7 +96,7 @@ type ScrollIntoViewAction struct {
// Execute scrolls the element into view. // Execute scrolls the element into view.
func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error { func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector) script := core.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -105,7 +108,7 @@ type FocusAction struct {
// Execute focuses the element. // Execute focuses the element.
func (a FocusAction) Execute(ctx context.Context, wv *Webview) error { func (a FocusAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.focus()", a.Selector) script := core.Sprintf("document.querySelector(%q)?.focus()", a.Selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -117,7 +120,7 @@ type BlurAction struct {
// Execute removes focus from the element. // Execute removes focus from the element.
func (a BlurAction) Execute(ctx context.Context, wv *Webview) error { func (a BlurAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.blur()", a.Selector) script := core.Sprintf("document.querySelector(%q)?.blur()", a.Selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -129,7 +132,7 @@ type ClearAction struct {
// Execute clears the input value. // Execute clears the input value.
func (a ClearAction) Execute(ctx context.Context, wv *Webview) error { func (a ClearAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
el.value = ''; el.value = '';
@ -149,7 +152,7 @@ type SelectAction struct {
// Execute selects the option. // Execute selects the option.
func (a SelectAction) Execute(ctx context.Context, wv *Webview) error { func (a SelectAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
el.value = %q; el.value = %q;
@ -168,7 +171,7 @@ type CheckAction struct {
// Execute checks/unchecks the checkbox. // Execute checks/unchecks the checkbox.
func (a CheckAction) Execute(ctx context.Context, wv *Webview) error { func (a CheckAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el && el.checked !== %t) { if (el && el.checked !== %t) {
el.click(); el.click();
@ -191,7 +194,7 @@ func (a HoverAction) Execute(ctx context.Context, wv *Webview) error {
} }
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
return fmt.Errorf("element has no bounding box") return coreerr.E("HoverAction.Execute", "element has no bounding box", nil)
} }
x := elem.BoundingBox.X + elem.BoundingBox.Width/2 x := elem.BoundingBox.X + elem.BoundingBox.Width/2
@ -219,7 +222,7 @@ func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error {
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
// Fallback to JavaScript // Fallback to JavaScript
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}); const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window});
@ -266,7 +269,7 @@ func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error {
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
// Fallback to JavaScript // Fallback to JavaScript
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window}); const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window});
@ -374,7 +377,7 @@ type SetAttributeAction struct {
// Execute sets the attribute. // Execute sets the attribute.
func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error { func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.setAttribute(%q, %q)", a.Selector, a.Attribute, a.Value) script := core.Sprintf("document.querySelector(%q)?.setAttribute(%q, %q)", a.Selector, a.Attribute, a.Value)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -387,7 +390,7 @@ type RemoveAttributeAction struct {
// Execute removes the attribute. // Execute removes the attribute.
func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error { func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute) script := core.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -400,7 +403,7 @@ type SetValueAction struct {
// Execute sets the value. // Execute sets the value.
func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error { func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
el.value = %q; el.value = %q;
@ -459,7 +462,7 @@ func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence {
func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error { func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error {
for i, action := range s.actions { for i, action := range s.actions {
if err := action.Execute(ctx, wv); err != nil { if err := action.Execute(ctx, wv); err != nil {
return fmt.Errorf("action %d failed: %w", i, err) return coreerr.E("ActionSequence.Execute", core.Sprintf("action %d failed", i), err)
} }
} }
return nil return nil
@ -492,18 +495,18 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
// Get source and target elements // Get source and target elements
source, err := wv.querySelector(ctx, sourceSelector) source, err := wv.querySelector(ctx, sourceSelector)
if err != nil { if err != nil {
return fmt.Errorf("source element not found: %w", err) return coreerr.E("Webview.DragAndDrop", "source element not found", err)
} }
if source.BoundingBox == nil { if source.BoundingBox == nil {
return fmt.Errorf("source element has no bounding box") return coreerr.E("Webview.DragAndDrop", "source element has no bounding box", nil)
} }
target, err := wv.querySelector(ctx, targetSelector) target, err := wv.querySelector(ctx, targetSelector)
if err != nil { if err != nil {
return fmt.Errorf("target element not found: %w", err) return coreerr.E("Webview.DragAndDrop", "target element not found", err)
} }
if target.BoundingBox == nil { if target.BoundingBox == nil {
return fmt.Errorf("target element has no bounding box") return coreerr.E("Webview.DragAndDrop", "target element has no bounding box", nil)
} }
// Calculate center points // Calculate center points

View file

@ -1,10 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"fmt"
"strings"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// AngularHelper provides Angular-specific testing utilities. // AngularHelper provides Angular-specific testing utilities.
@ -43,7 +45,7 @@ func (ah *AngularHelper) waitForAngular(ctx context.Context) error {
return err return err
} }
if !isAngular { if !isAngular {
return fmt.Errorf("not an Angular application") return coreerr.E("AngularHelper.waitForAngular", "not an Angular application", nil)
} }
// Wait for Zone.js stability // Wait for Zone.js stability
@ -91,6 +93,21 @@ func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) {
func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error { func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
script := ` script := `
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const pollZone = () => {
if (!window.Zone || !window.Zone.current) {
resolve(true);
return;
}
const inner = window.Zone.current._inner || window.Zone.current;
if (!inner._hasPendingMicrotasks && !inner._hasPendingMacrotasks) {
resolve(true);
return;
}
setTimeout(pollZone, 50);
};
// Get the root elements // Get the root elements
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
if (roots.length === 0) { if (roots.length === 0) {
@ -119,28 +136,7 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
} }
if (!zone) { if (!zone) {
// Fallback: check window.Zone pollZone();
if (window.Zone && window.Zone.current && window.Zone.current._inner) {
const isStable = !window.Zone.current._inner._hasPendingMicrotasks &&
!window.Zone.current._inner._hasPendingMacrotasks;
if (isStable) {
resolve(true);
} else {
// Poll for stability
let attempts = 0;
const poll = setInterval(() => {
attempts++;
const stable = !window.Zone.current._inner._hasPendingMicrotasks &&
!window.Zone.current._inner._hasPendingMacrotasks;
if (stable || attempts > 100) {
clearInterval(poll);
resolve(stable);
}
}, 50);
}
} else {
resolve(true);
}
return; return;
} }
@ -151,30 +147,28 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
} }
// Wait for stability // Wait for stability
const sub = zone.onStable.subscribe(() => { try {
sub.unsubscribe(); const sub = zone.onStable.subscribe(() => {
resolve(true); sub.unsubscribe();
}); resolve(true);
});
// Timeout fallback } catch (e) {
setTimeout(() => { pollZone();
sub.unsubscribe(); }
resolve(zone.isStable);
}, 5000);
}) })
` `
ticker := time.NewTicker(100 * time.Millisecond) result, err := ah.wv.evaluate(ctx, script)
defer ticker.Stop()
// First evaluate the promise
_, err := ah.wv.evaluate(ctx, script)
if err != nil { if err != nil {
// If the script fails, fall back to simple polling // If the script fails, fall back to simple polling
return ah.pollForStability(ctx) return ah.pollForStability(ctx)
} }
return nil if stable, ok := result.(bool); ok && stable {
return nil
}
return ah.pollForStability(ctx)
} }
// pollForStability polls for Angular stability as a fallback. // pollForStability polls for Angular stability as a fallback.
@ -213,7 +207,7 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
if (roots.length === 0) { if (roots.length === 0) {
@ -238,7 +232,7 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
if err != nil { if err != nil {
return fmt.Errorf("failed to navigate: %w", err) return coreerr.E("AngularHelper.NavigateByRouter", "failed to navigate", err)
} }
// Wait for navigation to complete // Wait for navigation to complete
@ -279,13 +273,13 @@ func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) {
} }
if result == nil { if result == nil {
return nil, fmt.Errorf("could not get router state") return nil, coreerr.E("AngularHelper.GetRouterState", "could not get router state", nil)
} }
// Parse result // Parse result
resultMap, ok := result.(map[string]any) resultMap, ok := result.(map[string]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid router state format") return nil, coreerr.E("AngularHelper.GetRouterState", "invalid router state format", nil)
} }
state := &AngularRouterState{ state := &AngularRouterState{
@ -330,19 +324,21 @@ func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (an
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
if (!element) { const propertyName = %s;
throw new Error('Element not found: %s'); const element = document.querySelector(selector);
} if (!element) {
const component = window.ng.probe(element).componentInstance; throw new Error('Element not found: ' + selector);
if (!component) { }
throw new Error('No Angular component found on element'); const component = window.ng.probe(element).componentInstance;
} if (!component) {
return component[%q]; throw new Error('No Angular component found on element');
})() }
`, selector, selector, propertyName) return component[propertyName];
})()
`, formatJSValue(selector), formatJSValue(propertyName))
return ah.wv.evaluate(ctx, script) return ah.wv.evaluate(ctx, script)
} }
@ -352,27 +348,29 @@ func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, val
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
if (!element) { const propertyName = %s;
throw new Error('Element not found: %s'); const element = document.querySelector(selector);
} if (!element) {
const component = window.ng.probe(element).componentInstance; throw new Error('Element not found: ' + selector);
if (!component) { }
throw new Error('No Angular component found on element'); const component = window.ng.probe(element).componentInstance;
} if (!component) {
component[%q] = %v; throw new Error('No Angular component found on element');
}
component[propertyName] = %s;
// Trigger change detection // Trigger change detection
const injector = window.ng.probe(element).injector; const injector = window.ng.probe(element).injector;
const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef'); const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef');
if (appRef) { if (appRef) {
appRef.tick(); appRef.tick();
} }
return true; return true;
})() })()
`, selector, selector, propertyName, formatJSValue(value)) `, formatJSValue(selector), formatJSValue(propertyName), formatJSValue(value))
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err return err
@ -383,7 +381,7 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
var argsStr strings.Builder argsStr := core.NewBuilder()
for i, arg := range args { for i, arg := range args {
if i > 0 { if i > 0 {
argsStr.WriteString(", ") argsStr.WriteString(", ")
@ -391,30 +389,32 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
argsStr.WriteString(formatJSValue(arg)) argsStr.WriteString(formatJSValue(arg))
} }
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
if (!element) { const methodName = %s;
throw new Error('Element not found: %s'); const element = document.querySelector(selector);
} if (!element) {
const component = window.ng.probe(element).componentInstance; throw new Error('Element not found: ' + selector);
if (!component) { }
throw new Error('No Angular component found on element'); const component = window.ng.probe(element).componentInstance;
} if (!component) {
if (typeof component[%q] !== 'function') { throw new Error('No Angular component found on element');
throw new Error('Method not found: %s'); }
} if (typeof component[methodName] !== 'function') {
const result = component[%q](%s); throw new Error('Method not found: ' + methodName);
}
const result = component[methodName](%s);
// Trigger change detection // Trigger change detection
const injector = window.ng.probe(element).injector; const injector = window.ng.probe(element).injector;
const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef'); const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef');
if (appRef) { if (appRef) {
appRef.tick(); appRef.tick();
} }
return result; return result;
})() })()
`, selector, selector, methodName, methodName, methodName, argsStr.String()) `, formatJSValue(selector), formatJSValue(methodName), argsStr.String())
return ah.wv.evaluate(ctx, script) return ah.wv.evaluate(ctx, script)
} }
@ -452,7 +452,7 @@ func (ah *AngularHelper) GetService(serviceName string) (any, error) {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
for (const root of roots) { for (const root of roots) {
@ -479,7 +479,7 @@ func (ah *AngularHelper) WaitForComponent(selector string) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const element = document.querySelector(%q);
if (!element) return false; if (!element) return false;
@ -521,17 +521,19 @@ func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) e
detailStr = formatJSValue(detail) detailStr = formatJSValue(detail)
} }
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
if (!element) { const eventName = %s;
throw new Error('Element not found: %s'); const element = document.querySelector(selector);
} if (!element) {
const event = new CustomEvent(%q, { bubbles: true, detail: %s }); throw new Error('Element not found: ' + selector);
element.dispatchEvent(event); }
return true; const event = new CustomEvent(eventName, { bubbles: true, detail: %s });
})() element.dispatchEvent(event);
`, selector, selector, eventName, detailStr) return true;
})()
`, formatJSValue(selector), formatJSValue(eventName), detailStr)
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err return err
@ -542,7 +544,7 @@ func (ah *AngularHelper) GetNgModel(selector string) (any, error) {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const element = document.querySelector(%q);
if (!element) return null; if (!element) return null;
@ -569,18 +571,19 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
if (!element) { const element = document.querySelector(selector);
throw new Error('Element not found: %s'); if (!element) {
} throw new Error('Element not found: ' + selector);
}
element.value = %v; element.value = %s;
element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true }));
// Trigger change detection // Trigger change detection
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
for (const root of roots) { for (const root of roots) {
try { try {
@ -593,9 +596,9 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
} catch (e) {} } catch (e) {}
} }
return true; return true;
})() })()
`, selector, selector, formatJSValue(value)) `, formatJSValue(selector), formatJSValue(value))
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err return err
@ -611,17 +614,15 @@ func getString(m map[string]any, key string) string {
} }
func formatJSValue(v any) string { func formatJSValue(v any) string {
switch val := v.(type) { r := core.JSONMarshal(v)
case string: if r.OK {
return fmt.Sprintf("%q", val) return string(r.Value.([]byte))
case bool:
if val {
return "true"
}
return "false"
case nil:
return "null"
default:
return fmt.Sprintf("%v", val)
} }
r = core.JSONMarshal(core.Sprint(v))
if r.OK {
return string(r.Value.([]byte))
}
return "null"
} }

673
audit_issue2_test.go Normal file
View file

@ -0,0 +1,673 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"time"
core "dappco.re/go/core"
"github.com/gorilla/websocket"
)
type fakeCDPServer struct {
t *testing.T
server *httptest.Server
mu sync.Mutex
nextTarget int
targets map[string]*fakeCDPTarget
}
type fakeCDPTarget struct {
server *fakeCDPServer
id string
onConnect func(*fakeCDPTarget)
onMessage func(*fakeCDPTarget, cdpMessage)
connMu sync.Mutex
conn *websocket.Conn
received chan cdpMessage
connected chan struct{}
closed chan struct{}
connectedOnce sync.Once
closedOnce sync.Once
}
func newFakeCDPServer(t *testing.T) *fakeCDPServer {
t.Helper()
server := &fakeCDPServer{
t: t,
targets: make(map[string]*fakeCDPTarget),
}
server.server = httptest.NewServer(http.HandlerFunc(server.handle))
server.addTarget("target-1")
t.Cleanup(server.Close)
return server
}
func (s *fakeCDPServer) Close() {
s.server.Close()
}
func (s *fakeCDPServer) DebugURL() string {
return s.server.URL
}
func (s *fakeCDPServer) addTarget(id string) *fakeCDPTarget {
s.mu.Lock()
defer s.mu.Unlock()
target := &fakeCDPTarget{
server: s,
id: id,
received: make(chan cdpMessage, 16),
connected: make(chan struct{}),
closed: make(chan struct{}),
}
s.targets[id] = target
return target
}
func (s *fakeCDPServer) newTarget() *fakeCDPTarget {
s.mu.Lock()
s.nextTarget++
id := core.Sprintf("target-%d", s.nextTarget+1)
s.mu.Unlock()
return s.addTarget(id)
}
func (s *fakeCDPServer) primaryTarget() *fakeCDPTarget {
s.mu.Lock()
defer s.mu.Unlock()
return s.targets["target-1"]
}
func (s *fakeCDPServer) handle(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/json":
s.handleListTargets(w)
case r.URL.Path == "/json/new":
s.handleNewTarget(w)
case r.URL.Path == "/json/version":
s.writeJSON(w, map[string]string{
"Browser": "Chrome/123.0",
})
case core.HasPrefix(r.URL.Path, "/devtools/page/"):
s.handleWebSocket(w, r, core.TrimPrefix(r.URL.Path, "/devtools/page/"))
default:
http.NotFound(w, r)
}
}
func (s *fakeCDPServer) handleListTargets(w http.ResponseWriter) {
s.mu.Lock()
targets := make([]TargetInfo, 0, len(s.targets))
for id := range s.targets {
targets = append(targets, TargetInfo{
ID: id,
Type: "page",
Title: id,
URL: "about:blank",
WebSocketDebuggerURL: s.webSocketURL(id),
})
}
s.mu.Unlock()
s.writeJSON(w, targets)
}
func (s *fakeCDPServer) handleNewTarget(w http.ResponseWriter) {
target := s.newTarget()
s.writeJSON(w, TargetInfo{
ID: target.id,
Type: "page",
Title: target.id,
URL: "about:blank",
WebSocketDebuggerURL: s.webSocketURL(target.id),
})
}
func (s *fakeCDPServer) handleWebSocket(w http.ResponseWriter, r *http.Request, id string) {
s.mu.Lock()
target := s.targets[id]
s.mu.Unlock()
if target == nil {
http.NotFound(w, r)
return
}
upgrader := websocket.Upgrader{
CheckOrigin: func(*http.Request) bool { return true },
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
s.t.Fatalf("failed to upgrade test WebSocket: %v", err)
}
target.attach(conn)
}
func (s *fakeCDPServer) writeJSON(w http.ResponseWriter, value any) {
s.t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(value); err != nil {
s.t.Fatalf("failed to encode JSON: %v", err)
}
}
func (s *fakeCDPServer) webSocketURL(id string) string {
wsURL, err := url.Parse(s.server.URL)
if err != nil {
s.t.Fatalf("failed to parse test server URL: %v", err)
}
if wsURL.Scheme == "http" {
wsURL.Scheme = "ws"
} else {
wsURL.Scheme = "wss"
}
wsURL.Path = "/devtools/page/" + id
wsURL.RawQuery = ""
wsURL.Fragment = ""
return wsURL.String()
}
func (tgt *fakeCDPTarget) attach(conn *websocket.Conn) {
tgt.connMu.Lock()
tgt.conn = conn
tgt.connMu.Unlock()
tgt.connectedOnce.Do(func() {
close(tgt.connected)
})
go tgt.readLoop()
if tgt.onConnect != nil {
go tgt.onConnect(tgt)
}
}
func (tgt *fakeCDPTarget) readLoop() {
defer tgt.closedOnce.Do(func() {
close(tgt.closed)
})
for {
_, data, err := tgt.conn.ReadMessage()
if err != nil {
return
}
var msg cdpMessage
if r := core.JSONUnmarshal(data, &msg); !r.OK {
continue
}
select {
case tgt.received <- msg:
default:
}
if tgt.onMessage != nil {
tgt.onMessage(tgt, msg)
}
}
}
func (tgt *fakeCDPTarget) reply(id int64, result map[string]any) {
tgt.writeJSON(cdpResponse{
ID: id,
Result: result,
})
}
func (tgt *fakeCDPTarget) replyError(id int64, message string) {
tgt.writeJSON(cdpResponse{
ID: id,
Error: &cdpError{
Message: message,
},
})
}
func (tgt *fakeCDPTarget) replyValue(id int64, value any) {
tgt.reply(id, map[string]any{
"result": map[string]any{
"value": value,
},
})
}
func (tgt *fakeCDPTarget) writeJSON(value any) {
tgt.server.t.Helper()
tgt.connMu.Lock()
defer tgt.connMu.Unlock()
if tgt.conn == nil {
tgt.server.t.Fatal("test WebSocket connection was not established")
}
if err := tgt.conn.WriteJSON(value); err != nil {
tgt.server.t.Fatalf("failed to write test WebSocket message: %v", err)
}
}
func (tgt *fakeCDPTarget) closeWebSocket() {
tgt.connMu.Lock()
defer tgt.connMu.Unlock()
if tgt.conn != nil {
_ = tgt.conn.Close()
}
}
func (tgt *fakeCDPTarget) waitForMessage(tb testing.TB) cdpMessage {
tb.Helper()
select {
case msg := <-tgt.received:
return msg
case <-time.After(time.Second):
tb.Fatal("timed out waiting for CDP message")
return cdpMessage{}
}
}
func (tgt *fakeCDPTarget) waitConnected(tb testing.TB) {
tb.Helper()
select {
case <-tgt.connected:
case <-time.After(time.Second):
tb.Fatal("timed out waiting for WebSocket connection")
}
}
func (tgt *fakeCDPTarget) waitClosed(tb testing.TB) {
tb.Helper()
select {
case <-tgt.closed:
case <-time.After(time.Second):
tb.Fatal("timed out waiting for WebSocket closure")
}
}
func TestCDPClientClose_Good_UnblocksReadLoop(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
client, err := NewCDPClient(server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
target.waitConnected(t)
done := make(chan error, 1)
go func() {
done <- client.Close()
}()
select {
case err := <-done:
if err != nil {
t.Fatalf("Close returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("Close blocked waiting for readLoop")
}
}
func TestCDPClientReadLoop_Ugly_StopsOnTerminalReadError(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onConnect = func(target *fakeCDPTarget) {
target.closeWebSocket()
}
client, err := NewCDPClient(server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
select {
case <-client.done:
case <-time.After(time.Second):
t.Fatal("readLoop did not stop after terminal read error")
}
}
func TestCDPClientCloseTab_Good_ClosesTargetOnly(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Target.closeTarget" {
t.Fatalf("CloseTab sent %q, want Target.closeTarget", msg.Method)
}
if got := msg.Params["targetId"]; got != target.id {
t.Fatalf("Target.closeTarget targetId = %v, want %q", got, target.id)
}
target.reply(msg.ID, map[string]any{"success": true})
go func() {
time.Sleep(10 * time.Millisecond)
target.closeWebSocket()
}()
}
client, err := NewCDPClient(server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
if err := client.CloseTab(); err != nil {
t.Fatalf("CloseTab returned error: %v", err)
}
msg := target.waitForMessage(t)
if msg.Method == "Browser.close" {
t.Fatal("CloseTab closed the whole browser")
}
}
func TestCDPClientDispatchEvent_Good_HandlerParamsAreIsolated(t *testing.T) {
client := &CDPClient{
handlers: make(map[string][]func(map[string]any)),
}
firstDone := make(chan map[string]any, 1)
secondDone := make(chan map[string]any, 1)
client.OnEvent("Runtime.testEvent", func(params map[string]any) {
params["value"] = "mutated"
params["nested"].(map[string]any)["count"] = 1
params["items"].([]any)[0].(map[string]any)["id"] = "changed"
firstDone <- params
})
client.OnEvent("Runtime.testEvent", func(params map[string]any) {
secondDone <- params
})
original := map[string]any{
"nested": map[string]any{"count": 0},
"items": []any{map[string]any{"id": "original"}},
}
client.dispatchEvent("Runtime.testEvent", original)
select {
case <-firstDone:
case <-time.After(time.Second):
t.Fatal("first handler did not run")
}
var secondParams map[string]any
select {
case secondParams = <-secondDone:
case <-time.After(time.Second):
t.Fatal("second handler did not run")
}
if _, ok := secondParams["value"]; ok {
t.Fatal("second handler observed first handler mutation")
}
if got := secondParams["nested"].(map[string]any)["count"]; got != 0 {
t.Fatalf("second handler nested count = %v, want 0", got)
}
if got := secondParams["items"].([]any)[0].(map[string]any)["id"]; got != "original" {
t.Fatalf("second handler slice payload = %v, want %q", got, "original")
}
if got := original["nested"].(map[string]any)["count"]; got != 0 {
t.Fatalf("original params were mutated: nested count = %v", got)
}
}
func TestNewCDPClient_Bad_RejectsCrossHostWebSocket(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/json" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode([]TargetInfo{{
ID: "target-1",
Type: "page",
WebSocketDebuggerURL: "ws://example.com/devtools/page/target-1",
}}); err != nil {
t.Fatalf("failed to encode targets: %v", err)
}
}))
defer server.Close()
_, err := NewCDPClient(server.URL)
if err == nil {
t.Fatal("NewCDPClient succeeded with a cross-host WebSocket URL")
}
if !core.Contains(err.Error(), "invalid target WebSocket URL") {
t.Fatalf("NewCDPClient error = %v, want cross-host WebSocket validation failure", err)
}
}
func TestWebviewNew_Bad_ClosesClientWhenEnableConsoleFails(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.enable" {
t.Fatalf("enableConsole sent %q before Runtime.enable failed", msg.Method)
}
target.replyError(msg.ID, "runtime disabled")
}
_, err := New(
WithTimeout(250*time.Millisecond),
WithDebugURL(server.DebugURL()),
)
if err == nil {
t.Fatal("New succeeded when Runtime.enable failed")
}
target.waitClosed(t)
}
func TestAngularHelperWaitForZoneStability_Good_AwaitsPromise(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, true)
}
client, err := NewCDPClient(server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
defer func() { _ = client.Close() }()
wv := &Webview{
client: client,
ctx: context.Background(),
timeout: time.Second,
}
ah := NewAngularHelper(wv)
if err := ah.waitForZoneStability(context.Background()); err != nil {
t.Fatalf("waitForZoneStability returned error: %v", err)
}
msg := target.waitForMessage(t)
if got := msg.Params["awaitPromise"]; got != true {
t.Fatalf("Runtime.evaluate awaitPromise = %v, want true", got)
}
if got := msg.Params["returnByValue"]; got != true {
t.Fatalf("Runtime.evaluate returnByValue = %v, want true", got)
}
}
func TestAngularHelperSetNgModel_Good_EscapesSelectorAndValue(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, true)
}
client, err := NewCDPClient(server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
defer func() { _ = client.Close() }()
wv := &Webview{
client: client,
ctx: context.Background(),
timeout: time.Second,
}
ah := NewAngularHelper(wv)
selector := `input[name="x'];window.hacked=true;//"]`
value := `";window.hacked=true;//`
if err := ah.SetNgModel(selector, value); err != nil {
t.Fatalf("SetNgModel returned error: %v", err)
}
expression, _ := target.waitForMessage(t).Params["expression"].(string)
if !core.Contains(expression, "const selector = "+formatJSValue(selector)+";") {
t.Fatalf("expression did not contain safely quoted selector: %s", expression)
}
if !core.Contains(expression, "element.value = "+formatJSValue(value)+";") {
t.Fatalf("expression did not contain safely quoted value: %s", expression)
}
if core.Contains(expression, "throw new Error('Element not found: "+selector+"')") {
t.Fatalf("expression still embedded selector directly in error text: %s", expression)
}
}
func TestConsoleWatcherWaitForMessage_Good_IsolatesTemporaryHandlers(t *testing.T) {
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
results := make(chan string, 2)
errorsCh := make(chan error, 2)
go func() {
msg, err := cw.WaitForMessage(ctx, ConsoleFilter{Type: "error"})
if err != nil {
errorsCh <- err
return
}
results <- "error:" + msg.Text
}()
go func() {
msg, err := cw.WaitForMessage(ctx, ConsoleFilter{Type: "log"})
if err != nil {
errorsCh <- err
return
}
results <- "log:" + msg.Text
}()
time.Sleep(20 * time.Millisecond)
cw.addMessage(ConsoleMessage{Type: "error", Text: "first"})
time.Sleep(20 * time.Millisecond)
cw.addMessage(ConsoleMessage{Type: "log", Text: "second"})
got := make(map[string]bool, 2)
for range 2 {
select {
case err := <-errorsCh:
t.Fatalf("WaitForMessage returned error: %v", err)
case result := <-results:
got[result] = true
case <-time.After(time.Second):
t.Fatal("timed out waiting for console waiter results")
}
}
if !got["error:first"] || !got["log:second"] {
t.Fatalf("unexpected console waiter results: %#v", got)
}
if len(cw.handlers) != 0 {
t.Fatalf("temporary handlers leaked: %d", len(cw.handlers))
}
}
func TestExceptionWatcherWaitForException_Good_PreservesExistingHandlers(t *testing.T) {
ew := &ExceptionWatcher{
exceptions: make([]ExceptionInfo, 0),
handlers: make([]exceptionHandlerRegistration, 0),
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
waitDone := make(chan error, 1)
go func() {
_, err := ew.WaitForException(ctx)
waitDone <- err
}()
time.Sleep(20 * time.Millisecond)
var mu sync.Mutex
count := 0
ew.AddHandler(func(ExceptionInfo) {
mu.Lock()
defer mu.Unlock()
count++
})
ew.handleException(map[string]any{
"exceptionDetails": map[string]any{
"text": "first",
"lineNumber": float64(1),
"columnNumber": float64(1),
"url": "https://example.com/app.js",
},
})
select {
case err := <-waitDone:
if err != nil {
t.Fatalf("WaitForException returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for exception waiter")
}
ew.handleException(map[string]any{
"exceptionDetails": map[string]any{
"text": "second",
"lineNumber": float64(2),
"columnNumber": float64(1),
"url": "https://example.com/app.js",
},
})
mu.Lock()
defer mu.Unlock()
if count != 2 {
t.Fatalf("persistent handler count = %d, want 2", count)
}
if len(ew.handlers) != 1 {
t.Fatalf("unexpected handler count after waiter removal: %d", len(ew.handlers))
}
}

519
cdp.go
View file

@ -1,25 +1,45 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"encoding/json"
"fmt"
"io" "io"
"iter" "iter"
"net"
"net/http" "net/http"
"net/url"
"path"
"slices" "slices"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
const debugEndpointTimeout = 10 * time.Second
var (
defaultDebugHTTPClient = &http.Client{
Timeout: debugEndpointTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
errCDPClientClosed = core.NewError("cdp client closed")
)
// CDPClient handles communication with Chrome DevTools Protocol via WebSocket. // CDPClient handles communication with Chrome DevTools Protocol via WebSocket.
type CDPClient struct { type CDPClient struct {
mu sync.RWMutex mu sync.RWMutex
conn *websocket.Conn conn *websocket.Conn
debugURL string debugURL string
wsURL string debugBase *url.URL
wsURL string
// Message tracking // Message tracking
msgID atomic.Int64 msgID atomic.Int64
@ -31,9 +51,11 @@ type CDPClient struct {
handMu sync.RWMutex handMu sync.RWMutex
// Lifecycle // Lifecycle
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
done chan struct{} done chan struct{}
closeOnce sync.Once
closeErr error
} }
// cdpMessage represents a CDP protocol message. // cdpMessage represents a CDP protocol message.
@ -63,8 +85,8 @@ type cdpError struct {
Data string `json:"data,omitempty"` Data string `json:"data,omitempty"`
} }
// targetInfo represents Chrome DevTools target information. // TargetInfo represents Chrome DevTools target information.
type targetInfo struct { type TargetInfo struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Title string `json:"title"` Title string `json:"title"`
@ -75,87 +97,64 @@ type targetInfo struct {
// NewCDPClient creates a new CDP client connected to the given debug URL. // NewCDPClient creates a new CDP client connected to the given debug URL.
// The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222). // The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222).
func NewCDPClient(debugURL string) (*CDPClient, error) { func NewCDPClient(debugURL string) (*CDPClient, error) {
// Get available targets debugBase, err := parseDebugURL(debugURL)
resp, err := http.Get(debugURL + "/json")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get targets: %w", err) return nil, coreerr.E("CDPClient.New", "invalid debug URL", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read targets: %w", err)
} }
var targets []targetInfo ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
if err := json.Unmarshal(body, &targets); err != nil { defer cancel()
return nil, fmt.Errorf("failed to parse targets: %w", err)
targets, err := listTargetsAt(ctx, debugBase)
if err != nil {
return nil, coreerr.E("CDPClient.New", "failed to get targets", err)
} }
// Find a page target // Find a page target
var wsURL string var wsURL string
for _, t := range targets { for _, t := range targets {
if t.Type == "page" && t.WebSocketDebuggerURL != "" { if t.Type == "page" && t.WebSocketDebuggerURL != "" {
wsURL = t.WebSocketDebuggerURL wsURL, err = validateTargetWebSocketURL(debugBase, t.WebSocketDebuggerURL)
if err != nil {
return nil, coreerr.E("CDPClient.New", "invalid target WebSocket URL", err)
}
break break
} }
} }
if wsURL == "" { if wsURL == "" {
// Try to create a new target newTarget, err := createTargetAt(ctx, debugBase, "")
resp, err := http.Get(debugURL + "/json/new")
if err != nil { if err != nil {
return nil, fmt.Errorf("no page targets found and failed to create new: %w", err) return nil, coreerr.E("CDPClient.New", "no page targets found and failed to create new", err)
} }
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body) wsURL, err = validateTargetWebSocketURL(debugBase, newTarget.WebSocketDebuggerURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read new target: %w", err) return nil, coreerr.E("CDPClient.New", "invalid new target WebSocket URL", err)
} }
var newTarget targetInfo
if err := json.Unmarshal(body, &newTarget); err != nil {
return nil, fmt.Errorf("failed to parse new target: %w", err)
}
wsURL = newTarget.WebSocketDebuggerURL
} }
if wsURL == "" { if wsURL == "" {
return nil, fmt.Errorf("no WebSocket URL available") return nil, coreerr.E("CDPClient.New", "no WebSocket URL available", nil)
} }
// Connect to WebSocket // Connect to WebSocket
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to WebSocket: %w", err) return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err)
} }
ctx, cancel := context.WithCancel(context.Background()) return newCDPClient(debugBase, wsURL, conn), nil
client := &CDPClient{
conn: conn,
debugURL: debugURL,
wsURL: wsURL,
pending: make(map[int64]chan *cdpResponse),
handlers: make(map[string][]func(map[string]any)),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
// Start message reader
go client.readLoop()
return client, nil
} }
// Close closes the CDP connection. // Close closes the CDP connection.
func (c *CDPClient) Close() error { func (c *CDPClient) Close() error {
c.cancel() c.close(errCDPClientClosed)
<-c.done // Wait for read loop to finish <-c.done
return c.conn.Close() if c.closeErr != nil {
return coreerr.E("CDPClient.Close", "failed to close WebSocket", c.closeErr)
}
return nil
} }
// Call sends a CDP method call and waits for the response. // Call sends a CDP method call and waits for the response.
@ -165,7 +164,7 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
msg := cdpMessage{ msg := cdpMessage{
ID: id, ID: id,
Method: method, Method: method,
Params: params, Params: cloneMapAny(params),
} }
// Register response channel // Register response channel
@ -185,16 +184,18 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
err := c.conn.WriteJSON(msg) err := c.conn.WriteJSON(msg)
c.mu.Unlock() c.mu.Unlock()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to send message: %w", err) return nil, coreerr.E("CDPClient.Call", "failed to send message", err)
} }
// Wait for response // Wait for response
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case <-c.ctx.Done():
return nil, coreerr.E("CDPClient.Call", "client closed", errCDPClientClosed)
case resp := <-respCh: case resp := <-respCh:
if resp.Error != nil { if resp.Error != nil {
return nil, fmt.Errorf("CDP error %d: %s", resp.Error.Code, resp.Error.Message) return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil)
} }
return resp.Result, nil return resp.Result, nil
} }
@ -212,31 +213,35 @@ func (c *CDPClient) readLoop() {
defer close(c.done) defer close(c.done)
for { for {
select {
case <-c.ctx.Done():
return
default:
}
_, data, err := c.conn.ReadMessage() _, data, err := c.conn.ReadMessage()
if err != nil { if err != nil {
// Check if context was cancelled if c.ctx.Err() != nil {
select {
case <-c.ctx.Done():
return return
default: }
// Log error but continue (could be temporary) if isTerminalReadError(err) {
c.close(err)
return
}
var netErr net.Error
if core.As(err, &netErr) && netErr.Timeout() {
continue continue
} }
c.close(err)
return
} }
// Try to parse as response // Try to parse as response
var resp cdpResponse var resp cdpResponse
if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 { if r := core.JSONUnmarshal(data, &resp); r.OK && resp.ID > 0 {
c.pendMu.Lock() c.pendMu.Lock()
if ch, ok := c.pending[resp.ID]; ok { if ch, ok := c.pending[resp.ID]; ok {
respCopy := resp respCopy := resp
ch <- &respCopy select {
case ch <- &respCopy:
default:
}
} }
c.pendMu.Unlock() c.pendMu.Unlock()
continue continue
@ -244,7 +249,7 @@ func (c *CDPClient) readLoop() {
// Try to parse as event // Try to parse as event
var event cdpEvent var event cdpEvent
if err := json.Unmarshal(data, &event); err == nil && event.Method != "" { if r := core.JSONUnmarshal(data, &event); r.OK && event.Method != "" {
c.dispatchEvent(event.Method, event.Params) c.dispatchEvent(event.Method, event.Params)
} }
} }
@ -258,7 +263,8 @@ func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
for _, handler := range handlers { for _, handler := range handlers {
// Call handler in goroutine to avoid blocking // Call handler in goroutine to avoid blocking
go handler(params) handlerParams := cloneMapAny(params)
go handler(handlerParams)
} }
} }
@ -266,7 +272,7 @@ func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
func (c *CDPClient) Send(method string, params map[string]any) error { func (c *CDPClient) Send(method string, params map[string]any) error {
msg := cdpMessage{ msg := cdpMessage{
Method: method, Method: method,
Params: params, Params: cloneMapAny(params),
} }
c.mu.Lock() c.mu.Lock()
@ -286,90 +292,77 @@ func (c *CDPClient) WebSocketURL() string {
// NewTab creates a new browser tab and returns a new CDPClient connected to it. // NewTab creates a new browser tab and returns a new CDPClient connected to it.
func (c *CDPClient) NewTab(url string) (*CDPClient, error) { func (c *CDPClient) NewTab(url string) (*CDPClient, error) {
endpoint := c.debugURL + "/json/new" ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
if url != "" { defer cancel()
endpoint += "?" + url
}
resp, err := http.Get(endpoint) target, err := createTargetAt(ctx, c.debugBase, url)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create new tab: %w", err) return nil, coreerr.E("CDPClient.NewTab", "failed to create new tab", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var target targetInfo
if err := json.Unmarshal(body, &target); err != nil {
return nil, fmt.Errorf("failed to parse target: %w", err)
} }
if target.WebSocketDebuggerURL == "" { if target.WebSocketDebuggerURL == "" {
return nil, fmt.Errorf("no WebSocket URL for new tab") return nil, coreerr.E("CDPClient.NewTab", "no WebSocket URL for new tab", nil)
}
wsURL, err := validateTargetWebSocketURL(c.debugBase, target.WebSocketDebuggerURL)
if err != nil {
return nil, coreerr.E("CDPClient.NewTab", "invalid WebSocket URL for new tab", err)
} }
// Connect to new tab // Connect to new tab
conn, _, err := websocket.DefaultDialer.Dial(target.WebSocketDebuggerURL, nil) conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to new tab: %w", err) return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err)
} }
ctx, cancel := context.WithCancel(context.Background()) return newCDPClient(c.debugBase, wsURL, conn), nil
client := &CDPClient{
conn: conn,
debugURL: c.debugURL,
wsURL: target.WebSocketDebuggerURL,
pending: make(map[int64]chan *cdpResponse),
handlers: make(map[string][]func(map[string]any)),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
go client.readLoop()
return client, nil
} }
// CloseTab closes the current tab (target). // CloseTab closes the current tab (target).
func (c *CDPClient) CloseTab() error { func (c *CDPClient) CloseTab() error {
// Extract target ID from WebSocket URL targetID, err := targetIDFromWebSocketURL(c.wsURL)
// Format: ws://host:port/devtools/page/TARGET_ID if err != nil {
// We'll use the Browser.close target API return coreerr.E("CDPClient.CloseTab", "failed to determine target ID", err)
}
ctx := context.Background() ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
_, err := c.Call(ctx, "Browser.close", nil) defer cancel()
return err
result, err := c.Call(ctx, "Target.closeTarget", map[string]any{
"targetId": targetID,
})
if err != nil {
return coreerr.E("CDPClient.CloseTab", "failed to close target", err)
}
if success, ok := result["success"].(bool); ok && !success {
return coreerr.E("CDPClient.CloseTab", "target close was not acknowledged", nil)
}
return c.Close()
} }
// ListTargets returns all available targets. // ListTargets returns all available targets.
func ListTargets(debugURL string) ([]targetInfo, error) { func ListTargets(debugURL string) ([]TargetInfo, error) {
resp, err := http.Get(debugURL + "/json") debugBase, err := parseDebugURL(debugURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get targets: %w", err) return nil, coreerr.E("ListTargets", "invalid debug URL", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read targets: %w", err)
} }
var targets []targetInfo ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
if err := json.Unmarshal(body, &targets); err != nil { defer cancel()
return nil, fmt.Errorf("failed to parse targets: %w", err)
targets, err := listTargetsAt(ctx, debugBase)
if err != nil {
return nil, coreerr.E("ListTargets", "failed to get targets", err)
} }
return targets, nil return targets, nil
} }
// ListTargetsAll returns an iterator over all available targets. // ListTargetsAll returns an iterator over all available targets.
func ListTargetsAll(debugURL string) iter.Seq[targetInfo] { func ListTargetsAll(debugURL string) iter.Seq[TargetInfo] {
return func(yield func(targetInfo) bool) { return func(yield func(TargetInfo) bool) {
targets, err := ListTargets(debugURL) targets, err := ListTargets(debugURL)
if err != nil { if err != nil {
return return
@ -384,21 +377,261 @@ func ListTargetsAll(debugURL string) iter.Seq[targetInfo] {
// GetVersion returns Chrome version information. // GetVersion returns Chrome version information.
func GetVersion(debugURL string) (map[string]string, error) { func GetVersion(debugURL string) (map[string]string, error) {
resp, err := http.Get(debugURL + "/json/version") debugBase, err := parseDebugURL(debugURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get version: %w", err) return nil, coreerr.E("GetVersion", "invalid debug URL", err)
}
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
defer cancel()
body, err := doDebugRequest(ctx, debugBase, "/json/version", "")
if err != nil {
return nil, coreerr.E("GetVersion", "failed to get version", err)
}
var version map[string]string
if r := core.JSONUnmarshal(body, &version); !r.OK {
return nil, coreerr.E("GetVersion", "failed to parse version", nil)
}
return version, nil
}
func newCDPClient(debugBase *url.URL, wsURL string, conn *websocket.Conn) *CDPClient {
ctx, cancel := context.WithCancel(context.Background())
baseCopy := *debugBase
client := &CDPClient{
conn: conn,
debugURL: canonicalDebugURL(&baseCopy),
debugBase: &baseCopy,
wsURL: wsURL,
pending: make(map[int64]chan *cdpResponse),
handlers: make(map[string][]func(map[string]any)),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
go client.readLoop()
return client
}
func parseDebugURL(raw string) (*url.URL, error) {
debugURL, err := url.Parse(raw)
if err != nil {
return nil, err
}
if debugURL.Scheme != "http" && debugURL.Scheme != "https" {
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must use http or https", nil)
}
if debugURL.Host == "" {
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL host is required", nil)
}
if debugURL.User != nil {
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must not include credentials", nil)
}
if debugURL.RawQuery != "" || debugURL.Fragment != "" {
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must not include query or fragment", nil)
}
if debugURL.Path == "" {
debugURL.Path = "/"
}
if debugURL.Path != "/" {
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL must point at the DevTools root", nil)
}
return debugURL, nil
}
func canonicalDebugURL(debugURL *url.URL) string {
return core.TrimSuffix(debugURL.String(), "/")
}
func doDebugRequest(ctx context.Context, debugBase *url.URL, endpoint, rawQuery string) ([]byte, error) {
reqURL := *debugBase
reqURL.Path = endpoint
reqURL.RawPath = ""
reqURL.RawQuery = rawQuery
reqURL.Fragment = ""
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL.String(), nil)
if err != nil {
return nil, err
}
resp, err := defaultDebugHTTPClient.Do(req)
if err != nil {
return nil, err
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read version: %w", err) return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, coreerr.E("CDPClient.doDebugRequest", "debug endpoint returned "+resp.Status, nil)
} }
var version map[string]string return body, nil
if err := json.Unmarshal(body, &version); err != nil { }
return nil, fmt.Errorf("failed to parse version: %w", err)
} func listTargetsAt(ctx context.Context, debugBase *url.URL) ([]TargetInfo, error) {
body, err := doDebugRequest(ctx, debugBase, "/json", "")
return version, nil if err != nil {
return nil, err
}
var targets []TargetInfo
if r := core.JSONUnmarshal(body, &targets); !r.OK {
return nil, coreerr.E("CDPClient.listTargetsAt", "failed to parse targets", nil)
}
return targets, nil
}
func createTargetAt(ctx context.Context, debugBase *url.URL, pageURL string) (*TargetInfo, error) {
rawQuery := ""
if pageURL != "" {
rawQuery = url.QueryEscape(pageURL)
}
body, err := doDebugRequest(ctx, debugBase, "/json/new", rawQuery)
if err != nil {
return nil, err
}
var target TargetInfo
if r := core.JSONUnmarshal(body, &target); !r.OK {
return nil, coreerr.E("CDPClient.createTargetAt", "failed to parse target", nil)
}
return &target, nil
}
func validateTargetWebSocketURL(debugBase *url.URL, raw string) (string, error) {
wsURL, err := url.Parse(raw)
if err != nil {
return "", err
}
if wsURL.Scheme != "ws" && wsURL.Scheme != "wss" {
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must use ws or wss", nil)
}
if !sameEndpointHost(debugBase, wsURL) {
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must match debug URL host", nil)
}
return wsURL.String(), nil
}
func sameEndpointHost(httpURL, wsURL *url.URL) bool {
return strings.EqualFold(httpURL.Hostname(), wsURL.Hostname()) && normalisedPort(httpURL) == normalisedPort(wsURL)
}
func normalisedPort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch u.Scheme {
case "http", "ws":
return "80"
case "https", "wss":
return "443"
default:
return ""
}
}
func targetIDFromWebSocketURL(raw string) (string, error) {
wsURL, err := url.Parse(raw)
if err != nil {
return "", err
}
targetID := path.Base(core.TrimSuffix(wsURL.Path, "/"))
if targetID == "." || targetID == "/" || targetID == "" {
return "", coreerr.E("CDPClient.targetIDFromWebSocketURL", "missing target ID in WebSocket URL", nil)
}
return targetID, nil
}
func (c *CDPClient) close(reason error) {
c.closeOnce.Do(func() {
c.cancel()
c.failPending(reason)
c.mu.Lock()
err := c.conn.Close()
c.mu.Unlock()
if err != nil && !isTerminalReadError(err) {
c.closeErr = err
}
})
}
func (c *CDPClient) failPending(err error) {
c.pendMu.Lock()
defer c.pendMu.Unlock()
for id, ch := range c.pending {
resp := &cdpResponse{
ID: id,
Error: &cdpError{
Message: err.Error(),
},
}
select {
case ch <- resp:
default:
}
}
}
func isTerminalReadError(err error) bool {
if err == nil {
return false
}
if core.Is(err, net.ErrClosed) || core.Is(err, websocket.ErrCloseSent) {
return true
}
var closeErr *websocket.CloseError
return core.As(err, &closeErr)
}
func cloneMapAny(src map[string]any) map[string]any {
if src == nil {
return nil
}
dst := make(map[string]any, len(src))
for key, value := range src {
dst[key] = cloneAny(value)
}
return dst
}
func cloneSliceAny(src []any) []any {
if src == nil {
return nil
}
dst := make([]any, len(src))
for i, value := range src {
dst[i] = cloneAny(value)
}
return dst
}
func cloneAny(value any) any {
switch typed := value.(type) {
case map[string]any:
return cloneMapAny(typed)
case []any:
return cloneSliceAny(typed)
default:
return typed
}
} }

View file

@ -1,23 +1,26 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"fmt"
"iter" "iter"
"slices" "slices"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
core "dappco.re/go/core"
) )
// ConsoleWatcher provides advanced console message watching capabilities. // ConsoleWatcher provides advanced console message watching capabilities.
type ConsoleWatcher struct { type ConsoleWatcher struct {
mu sync.RWMutex mu sync.RWMutex
wv *Webview wv *Webview
messages []ConsoleMessage messages []ConsoleMessage
filters []ConsoleFilter filters []ConsoleFilter
limit int limit int
handlers []ConsoleHandler handlers []consoleHandlerRegistration
nextHandlerID atomic.Int64
} }
// ConsoleFilter filters console messages. // ConsoleFilter filters console messages.
@ -29,14 +32,19 @@ type ConsoleFilter struct {
// ConsoleHandler is called when a matching console message is received. // ConsoleHandler is called when a matching console message is received.
type ConsoleHandler func(msg ConsoleMessage) type ConsoleHandler func(msg ConsoleMessage)
type consoleHandlerRegistration struct {
id int64
handler ConsoleHandler
}
// NewConsoleWatcher creates a new console watcher for the webview. // NewConsoleWatcher creates a new console watcher for the webview.
func NewConsoleWatcher(wv *Webview) *ConsoleWatcher { func NewConsoleWatcher(wv *Webview) *ConsoleWatcher {
cw := &ConsoleWatcher{ cw := &ConsoleWatcher{
wv: wv, wv: wv,
messages: make([]ConsoleMessage, 0, 100), messages: make([]ConsoleMessage, 0, 1000),
filters: make([]ConsoleFilter, 0), filters: make([]ConsoleFilter, 0),
limit: 1000, limit: 1000,
handlers: make([]ConsoleHandler, 0), handlers: make([]consoleHandlerRegistration, 0),
} }
// Subscribe to console events from the webview's client // Subscribe to console events from the webview's client
@ -63,9 +71,30 @@ func (cw *ConsoleWatcher) ClearFilters() {
// AddHandler adds a handler for console messages. // AddHandler adds a handler for console messages.
func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) { func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) {
cw.addHandler(handler)
}
func (cw *ConsoleWatcher) addHandler(handler ConsoleHandler) int64 {
cw.mu.Lock() cw.mu.Lock()
defer cw.mu.Unlock() defer cw.mu.Unlock()
cw.handlers = append(cw.handlers, handler) id := cw.nextHandlerID.Add(1)
cw.handlers = append(cw.handlers, consoleHandlerRegistration{
id: id,
handler: handler,
})
return id
}
func (cw *ConsoleWatcher) removeHandler(id int64) {
cw.mu.Lock()
defer cw.mu.Unlock()
for i, registration := range cw.handlers {
if registration.id == id {
cw.handlers = slices.Delete(cw.handlers, i, i+1)
return
}
}
} }
// SetLimit sets the maximum number of messages to retain. // SetLimit sets the maximum number of messages to retain.
@ -187,13 +216,8 @@ func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilt
} }
} }
cw.AddHandler(handler) handlerID := cw.addHandler(handler)
defer func() { defer cw.removeHandler(handlerID)
cw.mu.Lock()
// Remove handler (simple implementation - in production you'd want a handle-based removal)
cw.handlers = cw.handlers[:len(cw.handlers)-1]
cw.mu.Unlock()
}()
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -248,14 +272,14 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
// Extract args // Extract args
args, _ := params["args"].([]any) args, _ := params["args"].([]any)
var text strings.Builder text := core.NewBuilder()
for i, arg := range args { for i, arg := range args {
if argMap, ok := arg.(map[string]any); ok { if argMap, ok := arg.(map[string]any); ok {
if val, ok := argMap["value"]; ok { if val, ok := argMap["value"]; ok {
if i > 0 { if i > 0 {
text.WriteString(" ") text.WriteString(" ")
} }
text.WriteString(fmt.Sprint(val)) text.WriteString(core.Sprint(val))
} }
} }
} }
@ -292,7 +316,8 @@ func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
// Enforce limit // Enforce limit
if len(cw.messages) >= cw.limit { if len(cw.messages) >= cw.limit {
cw.messages = cw.messages[len(cw.messages)-cw.limit+100:] drop := min(100, len(cw.messages))
cw.messages = cw.messages[drop:]
} }
cw.messages = append(cw.messages, msg) cw.messages = append(cw.messages, msg)
@ -301,8 +326,8 @@ func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
cw.mu.Unlock() cw.mu.Unlock()
// Call handlers // Call handlers
for _, handler := range handlers { for _, registration := range handlers {
handler(msg) registration.handler(msg)
} }
} }
@ -360,10 +385,16 @@ type ExceptionInfo struct {
// ExceptionWatcher watches for JavaScript exceptions. // ExceptionWatcher watches for JavaScript exceptions.
type ExceptionWatcher struct { type ExceptionWatcher struct {
mu sync.RWMutex mu sync.RWMutex
wv *Webview wv *Webview
exceptions []ExceptionInfo exceptions []ExceptionInfo
handlers []func(ExceptionInfo) handlers []exceptionHandlerRegistration
nextHandlerID atomic.Int64
}
type exceptionHandlerRegistration struct {
id int64
handler func(ExceptionInfo)
} }
// NewExceptionWatcher creates a new exception watcher. // NewExceptionWatcher creates a new exception watcher.
@ -371,7 +402,7 @@ func NewExceptionWatcher(wv *Webview) *ExceptionWatcher {
ew := &ExceptionWatcher{ ew := &ExceptionWatcher{
wv: wv, wv: wv,
exceptions: make([]ExceptionInfo, 0), exceptions: make([]ExceptionInfo, 0),
handlers: make([]func(ExceptionInfo), 0), handlers: make([]exceptionHandlerRegistration, 0),
} }
// Subscribe to exception events // Subscribe to exception events
@ -424,9 +455,30 @@ func (ew *ExceptionWatcher) Count() int {
// AddHandler adds a handler for exceptions. // AddHandler adds a handler for exceptions.
func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) { func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) {
ew.addHandler(handler)
}
func (ew *ExceptionWatcher) addHandler(handler func(ExceptionInfo)) int64 {
ew.mu.Lock() ew.mu.Lock()
defer ew.mu.Unlock() defer ew.mu.Unlock()
ew.handlers = append(ew.handlers, handler) id := ew.nextHandlerID.Add(1)
ew.handlers = append(ew.handlers, exceptionHandlerRegistration{
id: id,
handler: handler,
})
return id
}
func (ew *ExceptionWatcher) removeHandler(id int64) {
ew.mu.Lock()
defer ew.mu.Unlock()
for i, registration := range ew.handlers {
if registration.id == id {
ew.handlers = slices.Delete(ew.handlers, i, i+1)
return
}
}
} }
// WaitForException waits for an exception to be thrown. // WaitForException waits for an exception to be thrown.
@ -449,12 +501,8 @@ func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInf
} }
} }
ew.AddHandler(handler) handlerID := ew.addHandler(handler)
defer func() { defer ew.removeHandler(handlerID)
ew.mu.Lock()
ew.handlers = ew.handlers[:len(ew.handlers)-1]
ew.mu.Unlock()
}()
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -477,7 +525,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
url, _ := exceptionDetails["url"].(string) url, _ := exceptionDetails["url"].(string)
// Extract stack trace // Extract stack trace
var stackTrace strings.Builder stackTrace := core.NewBuilder()
if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok { if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok {
if frames, ok := st["callFrames"].([]any); ok { if frames, ok := st["callFrames"].([]any); ok {
for _, f := range frames { for _, f := range frames {
@ -486,7 +534,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
frameURL, _ := frame["url"].(string) frameURL, _ := frame["url"].(string)
frameLine, _ := frame["lineNumber"].(float64) frameLine, _ := frame["lineNumber"].(float64)
frameCol, _ := frame["columnNumber"].(float64) frameCol, _ := frame["columnNumber"].(float64)
stackTrace.WriteString(fmt.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol))) stackTrace.WriteString(core.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol)))
} }
} }
} }
@ -514,14 +562,14 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
ew.mu.Unlock() ew.mu.Unlock()
// Call handlers // Call handlers
for _, handler := range handlers { for _, registration := range handlers {
handler(info) registration.handler(info)
} }
} }
// FormatConsoleOutput formats console messages for display. // FormatConsoleOutput formats console messages for display.
func FormatConsoleOutput(messages []ConsoleMessage) string { func FormatConsoleOutput(messages []ConsoleMessage) string {
var output strings.Builder output := core.NewBuilder()
for _, msg := range messages { for _, msg := range messages {
prefix := "" prefix := ""
switch msg.Type { switch msg.Type {
@ -537,7 +585,7 @@ func FormatConsoleOutput(messages []ConsoleMessage) string {
prefix = "[LOG]" prefix = "[LOG]"
} }
timestamp := msg.Timestamp.Format("15:04:05.000") timestamp := msg.Timestamp.Format("15:04:05.000")
output.WriteString(fmt.Sprintf("%s %s %s\n", timestamp, prefix, msg.Text)) output.WriteString(core.Sprintf("%s %s %s\n", timestamp, prefix, msg.Text))
} }
return output.String() return output.String()
} }

160
docs/api-contract.md Normal file
View file

@ -0,0 +1,160 @@
---
title: API Contract
description: Extracted exported API contract for go-webview with signatures and test coverage notes.
---
# API Contract
This inventory covers the current exported surface of `dappco.re/go/core/webview`.
Coverage notes:
- Coverage is based on `webview_test.go`.
- `Indirect via ...` means the symbol is only exercised through another exported API or helper path.
- `None` means no evidence was found in the current test file.
| Kind | Name | Signature | Description | Test coverage |
| --- | --- | --- | --- | --- |
| Function | `FormatConsoleOutput` | `func FormatConsoleOutput(messages []ConsoleMessage) string` | FormatConsoleOutput formats console messages for display. | `TestFormatConsoleOutput_Good`, `TestFormatConsoleOutput_Good_Empty`. |
| Function | `GetVersion` | `func GetVersion(debugURL string) (map[string]string, error)` | GetVersion returns Chrome version information. | None in `webview_test.go`. |
| Function | `ListTargetsAll` | `func ListTargetsAll(debugURL string) iter.Seq[TargetInfo]` | ListTargetsAll returns an iterator over all available targets. | None in `webview_test.go`. |
| Type | `Action` | `type Action interface { Execute(ctx context.Context, wv *Webview) error }` | Action represents a browser action that can be performed. | Indirect via `TestActionSequence_Good`, `TestWaitAction_Good_ContextCancelled`, and `TestWaitAction_Good_ShortWait`. |
| Method | `Action.Execute` | `Execute(ctx context.Context, wv *Webview) error` | Runs an action against a Webview within the caller's context. | Indirect via `TestWaitAction_Good_ContextCancelled` and `TestWaitAction_Good_ShortWait`. |
| Type | `ActionSequence` | `type ActionSequence struct { /* unexported fields */ }` | ActionSequence represents a sequence of actions to execute. | `TestActionSequence_Good`. |
| Function | `NewActionSequence` | `func NewActionSequence() *ActionSequence` | NewActionSequence creates a new action sequence. | `TestActionSequence_Good`. |
| Method | `ActionSequence.Add` | `func (s *ActionSequence) Add(action Action) *ActionSequence` | Add adds an action to the sequence. | Indirect via `TestActionSequence_Good` builder chaining. |
| Method | `ActionSequence.Click` | `func (s *ActionSequence) Click(selector string) *ActionSequence` | Click adds a click action. | `TestActionSequence_Good`. |
| Method | `ActionSequence.Execute` | `func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error` | Execute executes all actions in the sequence. | None in `webview_test.go`. |
| Method | `ActionSequence.Navigate` | `func (s *ActionSequence) Navigate(url string) *ActionSequence` | Navigate adds a navigate action. | `TestActionSequence_Good`. |
| Method | `ActionSequence.Type` | `func (s *ActionSequence) Type(selector, text string) *ActionSequence` | Type adds a type action. | `TestActionSequence_Good`. |
| Method | `ActionSequence.Wait` | `func (s *ActionSequence) Wait(d time.Duration) *ActionSequence` | Wait adds a wait action. | `TestActionSequence_Good`. |
| Method | `ActionSequence.WaitForSelector` | `func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence` | WaitForSelector adds a wait for selector action. | `TestActionSequence_Good`. |
| Type | `AngularHelper` | `type AngularHelper struct { /* unexported fields */ }` | AngularHelper provides Angular-specific testing utilities. | None in `webview_test.go`. |
| Function | `NewAngularHelper` | `func NewAngularHelper(wv *Webview) *AngularHelper` | NewAngularHelper creates a new Angular helper for the webview. | None in `webview_test.go`. |
| Method | `AngularHelper.CallComponentMethod` | `func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args ...any) (any, error)` | CallComponentMethod calls a method on an Angular component. | None in `webview_test.go`. |
| Method | `AngularHelper.DispatchEvent` | `func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) error` | DispatchEvent dispatches a custom event on an element. | None in `webview_test.go`. |
| Method | `AngularHelper.GetComponentProperty` | `func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (any, error)` | GetComponentProperty gets a property from an Angular component. | None in `webview_test.go`. |
| Method | `AngularHelper.GetNgModel` | `func (ah *AngularHelper) GetNgModel(selector string) (any, error)` | GetNgModel gets the value of an ngModel-bound input. | None in `webview_test.go`. |
| Method | `AngularHelper.GetRouterState` | `func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error)` | GetRouterState returns the current Angular router state. | None in `webview_test.go`. |
| Method | `AngularHelper.GetService` | `func (ah *AngularHelper) GetService(serviceName string) (any, error)` | GetService gets an Angular service by token name. | None in `webview_test.go`. |
| Method | `AngularHelper.NavigateByRouter` | `func (ah *AngularHelper) NavigateByRouter(path string) error` | NavigateByRouter navigates using Angular Router. | None in `webview_test.go`. |
| Method | `AngularHelper.SetComponentProperty` | `func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, value any) error` | SetComponentProperty sets a property on an Angular component. | None in `webview_test.go`. |
| Method | `AngularHelper.SetNgModel` | `func (ah *AngularHelper) SetNgModel(selector string, value any) error` | SetNgModel sets the value of an ngModel-bound input. | None in `webview_test.go`. |
| Method | `AngularHelper.SetTimeout` | `func (ah *AngularHelper) SetTimeout(d time.Duration)` | SetTimeout sets the default timeout for Angular operations. | None in `webview_test.go`. |
| Method | `AngularHelper.TriggerChangeDetection` | `func (ah *AngularHelper) TriggerChangeDetection() error` | TriggerChangeDetection manually triggers Angular change detection. | None in `webview_test.go`. |
| Method | `AngularHelper.WaitForAngular` | `func (ah *AngularHelper) WaitForAngular() error` | WaitForAngular waits for Angular to finish all pending operations. | None in `webview_test.go`. |
| Method | `AngularHelper.WaitForComponent` | `func (ah *AngularHelper) WaitForComponent(selector string) error` | WaitForComponent waits for an Angular component to be present. | None in `webview_test.go`. |
| Type | `AngularRouterState` | `type AngularRouterState struct { URL string Fragment string Params map[string]string QueryParams map[string]string }` | AngularRouterState represents Angular router state. | `TestAngularRouterState_Good`. |
| Type | `BlurAction` | `type BlurAction struct { Selector string }` | BlurAction removes focus from an element. | `TestBlurAction_Good`. |
| Method | `BlurAction.Execute` | `func (a BlurAction) Execute(ctx context.Context, wv *Webview) error` | Execute removes focus from the element. | None in `webview_test.go`. |
| Type | `BoundingBox` | `type BoundingBox struct { X float64 Y float64 Width float64 Height float64 }` | BoundingBox represents the bounding rectangle of an element. | `TestBoundingBox_Good`; also nested in `TestElementInfo_Good`. |
| Type | `CDPClient` | `type CDPClient struct { /* unexported fields */ }` | CDPClient handles communication with Chrome DevTools Protocol via WebSocket. | None in `webview_test.go`. |
| Function | `NewCDPClient` | `func NewCDPClient(debugURL string) (*CDPClient, error)` | NewCDPClient creates a new CDP client connected to the given debug URL. | Indirect error-path coverage via `TestNew_Bad_InvalidDebugURL`. |
| Method | `CDPClient.Call` | `func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error)` | Call sends a CDP method call and waits for the response. | None in `webview_test.go`. |
| Method | `CDPClient.Close` | `func (c *CDPClient) Close() error` | Close closes the CDP connection. | None in `webview_test.go`. |
| Method | `CDPClient.CloseTab` | `func (c *CDPClient) CloseTab() error` | CloseTab closes the current tab (target). | None in `webview_test.go`. |
| Method | `CDPClient.DebugURL` | `func (c *CDPClient) DebugURL() string` | DebugURL returns the debug HTTP URL. | None in `webview_test.go`. |
| Method | `CDPClient.NewTab` | `func (c *CDPClient) NewTab(url string) (*CDPClient, error)` | NewTab creates a new browser tab and returns a new CDPClient connected to it. | None in `webview_test.go`. |
| Method | `CDPClient.OnEvent` | `func (c *CDPClient) OnEvent(method string, handler func(map[string]any))` | OnEvent registers a handler for CDP events. | None in `webview_test.go`. |
| Method | `CDPClient.Send` | `func (c *CDPClient) Send(method string, params map[string]any) error` | Send sends a fire-and-forget CDP message (no response expected). | None in `webview_test.go`. |
| Method | `CDPClient.WebSocketURL` | `func (c *CDPClient) WebSocketURL() string` | WebSocketURL returns the WebSocket URL being used. | None in `webview_test.go`. |
| Type | `CheckAction` | `type CheckAction struct { Selector string Checked bool }` | CheckAction checks or unchecks a checkbox. | `TestCheckAction_Good`. |
| Method | `CheckAction.Execute` | `func (a CheckAction) Execute(ctx context.Context, wv *Webview) error` | Execute checks/unchecks the checkbox. | None in `webview_test.go`. |
| Type | `ClearAction` | `type ClearAction struct { Selector string }` | ClearAction clears the value of an input element. | `TestClearAction_Good`. |
| Method | `ClearAction.Execute` | `func (a ClearAction) Execute(ctx context.Context, wv *Webview) error` | Execute clears the input value. | None in `webview_test.go`. |
| Type | `ClickAction` | `type ClickAction struct { Selector string }` | ClickAction represents a click action. | `TestClickAction_Good`. |
| Method | `ClickAction.Execute` | `func (a ClickAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the click action. | None in `webview_test.go`. |
| Type | `ConsoleFilter` | `type ConsoleFilter struct { Type string Pattern string }` | ConsoleFilter filters console messages. | `TestConsoleWatcherFilter_Good`, `TestConsoleWatcherFilteredMessages_Good`. |
| Type | `ConsoleHandler` | `type ConsoleHandler func(msg ConsoleMessage)` | ConsoleHandler is called when a matching console message is received. | Indirect via `TestConsoleWatcherHandler_Good`. |
| Type | `ConsoleMessage` | `type ConsoleMessage struct { Type string Text string Timestamp time.Time URL string Line int Column int }` | ConsoleMessage represents a captured console log message. | `TestConsoleMessage_Good`; also used by console watcher tests. |
| Type | `ConsoleWatcher` | `type ConsoleWatcher struct { /* unexported fields */ }` | ConsoleWatcher provides advanced console message watching capabilities. | `TestConsoleWatcherFilter_Good`, `TestConsoleWatcherCounts_Good`, `TestConsoleWatcherAddMessage_Good`, `TestConsoleWatcherHandler_Good`, `TestConsoleWatcherFilteredMessages_Good`. |
| Function | `NewConsoleWatcher` | `func NewConsoleWatcher(wv *Webview) *ConsoleWatcher` | NewConsoleWatcher creates a new console watcher for the webview. | None in `webview_test.go`. |
| Method | `ConsoleWatcher.AddFilter` | `func (cw *ConsoleWatcher) AddFilter(filter ConsoleFilter)` | AddFilter adds a filter to the watcher. | `TestConsoleWatcherFilter_Good`. |
| Method | `ConsoleWatcher.AddHandler` | `func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler)` | AddHandler adds a handler for console messages. | `TestConsoleWatcherHandler_Good`. |
| Method | `ConsoleWatcher.Clear` | `func (cw *ConsoleWatcher) Clear()` | Clear clears all captured messages. | `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.ClearFilters` | `func (cw *ConsoleWatcher) ClearFilters()` | ClearFilters removes all filters. | `TestConsoleWatcherFilter_Good`. |
| Method | `ConsoleWatcher.Count` | `func (cw *ConsoleWatcher) Count() int` | Count returns the number of captured messages. | `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.ErrorCount` | `func (cw *ConsoleWatcher) ErrorCount() int` | ErrorCount returns the number of error messages. | `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.Errors` | `func (cw *ConsoleWatcher) Errors() []ConsoleMessage` | Errors returns all error messages. | `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.ErrorsAll` | `func (cw *ConsoleWatcher) ErrorsAll() iter.Seq[ConsoleMessage]` | ErrorsAll returns an iterator over all error messages. | Indirect via `ConsoleWatcher.Errors()` in `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.FilteredMessages` | `func (cw *ConsoleWatcher) FilteredMessages() []ConsoleMessage` | FilteredMessages returns messages matching the current filters. | `TestConsoleWatcherFilteredMessages_Good`. |
| Method | `ConsoleWatcher.FilteredMessagesAll` | `func (cw *ConsoleWatcher) FilteredMessagesAll() iter.Seq[ConsoleMessage]` | FilteredMessagesAll returns an iterator over messages matching the current filters. | Indirect via `ConsoleWatcher.FilteredMessages()` in `TestConsoleWatcherFilteredMessages_Good`. |
| Method | `ConsoleWatcher.HasErrors` | `func (cw *ConsoleWatcher) HasErrors() bool` | HasErrors returns true if there are any error messages. | `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.Messages` | `func (cw *ConsoleWatcher) Messages() []ConsoleMessage` | Messages returns all captured messages. | None in `webview_test.go`. |
| Method | `ConsoleWatcher.MessagesAll` | `func (cw *ConsoleWatcher) MessagesAll() iter.Seq[ConsoleMessage]` | MessagesAll returns an iterator over all captured messages. | None in `webview_test.go`. |
| Method | `ConsoleWatcher.SetLimit` | `func (cw *ConsoleWatcher) SetLimit(limit int)` | SetLimit sets the maximum number of messages to retain. | None in `webview_test.go`. |
| Method | `ConsoleWatcher.WaitForError` | `func (cw *ConsoleWatcher) WaitForError(ctx context.Context) (*ConsoleMessage, error)` | WaitForError waits for an error message. | None in `webview_test.go`. |
| Method | `ConsoleWatcher.WaitForMessage` | `func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error)` | WaitForMessage waits for a message matching the filter. | None in `webview_test.go`. |
| Method | `ConsoleWatcher.Warnings` | `func (cw *ConsoleWatcher) Warnings() []ConsoleMessage` | Warnings returns all warning messages. | `TestConsoleWatcherCounts_Good`. |
| Method | `ConsoleWatcher.WarningsAll` | `func (cw *ConsoleWatcher) WarningsAll() iter.Seq[ConsoleMessage]` | WarningsAll returns an iterator over all warning messages. | Indirect via `ConsoleWatcher.Warnings()` in `TestConsoleWatcherCounts_Good`. |
| Type | `DoubleClickAction` | `type DoubleClickAction struct { Selector string }` | DoubleClickAction double-clicks an element. | `TestDoubleClickAction_Good`. |
| Method | `DoubleClickAction.Execute` | `func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error` | Execute double-clicks the element. | None in `webview_test.go`. |
| Type | `ElementInfo` | `type ElementInfo struct { NodeID int TagName string Attributes map[string]string InnerHTML string InnerText string BoundingBox *BoundingBox }` | ElementInfo represents information about a DOM element. | `TestElementInfo_Good`. |
| Type | `ExceptionInfo` | `type ExceptionInfo struct { Text string LineNumber int ColumnNumber int URL string StackTrace string Timestamp time.Time }` | ExceptionInfo represents information about a JavaScript exception. | `TestExceptionInfo_Good`; also used by `TestExceptionWatcher_Good`. |
| Type | `ExceptionWatcher` | `type ExceptionWatcher struct { /* unexported fields */ }` | ExceptionWatcher watches for JavaScript exceptions. | `TestExceptionWatcher_Good`. |
| Function | `NewExceptionWatcher` | `func NewExceptionWatcher(wv *Webview) *ExceptionWatcher` | NewExceptionWatcher creates a new exception watcher. | None in `webview_test.go`. |
| Method | `ExceptionWatcher.AddHandler` | `func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo))` | AddHandler adds a handler for exceptions. | None in `webview_test.go`. |
| Method | `ExceptionWatcher.Clear` | `func (ew *ExceptionWatcher) Clear()` | Clear clears all captured exceptions. | `TestExceptionWatcher_Good`. |
| Method | `ExceptionWatcher.Count` | `func (ew *ExceptionWatcher) Count() int` | Count returns the number of exceptions. | `TestExceptionWatcher_Good`. |
| Method | `ExceptionWatcher.Exceptions` | `func (ew *ExceptionWatcher) Exceptions() []ExceptionInfo` | Exceptions returns all captured exceptions. | `TestExceptionWatcher_Good`. |
| Method | `ExceptionWatcher.ExceptionsAll` | `func (ew *ExceptionWatcher) ExceptionsAll() iter.Seq[ExceptionInfo]` | ExceptionsAll returns an iterator over all captured exceptions. | Indirect via `ExceptionWatcher.Exceptions()` in `TestExceptionWatcher_Good`. |
| Method | `ExceptionWatcher.HasExceptions` | `func (ew *ExceptionWatcher) HasExceptions() bool` | HasExceptions returns true if there are any exceptions. | `TestExceptionWatcher_Good`. |
| Method | `ExceptionWatcher.WaitForException` | `func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error)` | WaitForException waits for an exception to be thrown. | None in `webview_test.go`. |
| Type | `FocusAction` | `type FocusAction struct { Selector string }` | FocusAction focuses an element. | `TestFocusAction_Good`. |
| Method | `FocusAction.Execute` | `func (a FocusAction) Execute(ctx context.Context, wv *Webview) error` | Execute focuses the element. | None in `webview_test.go`. |
| Type | `HoverAction` | `type HoverAction struct { Selector string }` | HoverAction hovers over an element. | `TestHoverAction_Good`. |
| Method | `HoverAction.Execute` | `func (a HoverAction) Execute(ctx context.Context, wv *Webview) error` | Execute hovers over the element. | None in `webview_test.go`. |
| Type | `NavigateAction` | `type NavigateAction struct { URL string }` | NavigateAction represents a navigation action. | `TestNavigateAction_Good`. |
| Method | `NavigateAction.Execute` | `func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the navigate action. | None in `webview_test.go`. |
| Type | `Option` | `type Option func(*Webview) error` | Option configures a Webview instance. | Used in `TestWithTimeout_Good`, `TestWithConsoleLimit_Good`, and `TestNew_Bad_InvalidDebugURL`. |
| Function | `WithConsoleLimit` | `func WithConsoleLimit(limit int) Option` | WithConsoleLimit sets the maximum number of console messages to retain. | `TestWithConsoleLimit_Good`. |
| Function | `WithDebugURL` | `func WithDebugURL(url string) Option` | WithDebugURL sets the Chrome DevTools debugging URL. | Indirect error-path coverage via `TestNew_Bad_InvalidDebugURL`. |
| Function | `WithTimeout` | `func WithTimeout(d time.Duration) Option` | WithTimeout sets the default timeout for operations. | `TestWithTimeout_Good`. |
| Type | `PressKeyAction` | `type PressKeyAction struct { Key string }` | PressKeyAction presses a key. | `TestPressKeyAction_Good`. |
| Method | `PressKeyAction.Execute` | `func (a PressKeyAction) Execute(ctx context.Context, wv *Webview) error` | Execute presses the key. | None in `webview_test.go`. |
| Type | `RemoveAttributeAction` | `type RemoveAttributeAction struct { Selector string Attribute string }` | RemoveAttributeAction removes an attribute from an element. | `TestRemoveAttributeAction_Good`. |
| Method | `RemoveAttributeAction.Execute` | `func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error` | Execute removes the attribute. | None in `webview_test.go`. |
| Type | `RightClickAction` | `type RightClickAction struct { Selector string }` | RightClickAction right-clicks an element. | `TestRightClickAction_Good`. |
| Method | `RightClickAction.Execute` | `func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error` | Execute right-clicks the element. | None in `webview_test.go`. |
| Type | `ScrollAction` | `type ScrollAction struct { X int Y int }` | ScrollAction represents a scroll action. | `TestScrollAction_Good`. |
| Method | `ScrollAction.Execute` | `func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the scroll action. | None in `webview_test.go`. |
| Type | `ScrollIntoViewAction` | `type ScrollIntoViewAction struct { Selector string }` | ScrollIntoViewAction scrolls an element into view. | `TestScrollIntoViewAction_Good`. |
| Method | `ScrollIntoViewAction.Execute` | `func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error` | Execute scrolls the element into view. | None in `webview_test.go`. |
| Type | `SelectAction` | `type SelectAction struct { Selector string Value string }` | SelectAction selects an option in a select element. | `TestSelectAction_Good`. |
| Method | `SelectAction.Execute` | `func (a SelectAction) Execute(ctx context.Context, wv *Webview) error` | Execute selects the option. | None in `webview_test.go`. |
| Type | `SetAttributeAction` | `type SetAttributeAction struct { Selector string Attribute string Value string }` | SetAttributeAction sets an attribute on an element. | `TestSetAttributeAction_Good`. |
| Method | `SetAttributeAction.Execute` | `func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error` | Execute sets the attribute. | None in `webview_test.go`. |
| Type | `SetValueAction` | `type SetValueAction struct { Selector string Value string }` | SetValueAction sets the value of an input element. | `TestSetValueAction_Good`. |
| Method | `SetValueAction.Execute` | `func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error` | Execute sets the value. | None in `webview_test.go`. |
| Type | `TargetInfo` | `type TargetInfo struct { ID string Type string Title string URL string WebSocketDebuggerURL string }` | TargetInfo represents Chrome DevTools target information. | `TestTargetInfo_Good`. |
| Function | `ListTargets` | `func ListTargets(debugURL string) ([]TargetInfo, error)` | ListTargets returns all available targets. | None in `webview_test.go`. |
| Type | `TypeAction` | `type TypeAction struct { Selector string Text string }` | TypeAction represents a typing action. | `TestTypeAction_Good`. |
| Method | `TypeAction.Execute` | `func (a TypeAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the type action. | None in `webview_test.go`. |
| Type | `WaitAction` | `type WaitAction struct { Duration time.Duration }` | WaitAction represents a wait action. | `TestWaitAction_Good`, `TestWaitAction_Good_ContextCancelled`, `TestWaitAction_Good_ShortWait`. |
| Method | `WaitAction.Execute` | `func (a WaitAction) Execute(ctx context.Context, wv *Webview) error` | Execute performs the wait action. | `TestWaitAction_Good_ContextCancelled`, `TestWaitAction_Good_ShortWait`. |
| Type | `WaitForSelectorAction` | `type WaitForSelectorAction struct { Selector string }` | WaitForSelectorAction represents waiting for a selector. | `TestWaitForSelectorAction_Good`. |
| Method | `WaitForSelectorAction.Execute` | `func (a WaitForSelectorAction) Execute(ctx context.Context, wv *Webview) error` | Execute waits for the selector to appear. | None in `webview_test.go`. |
| Type | `Webview` | `type Webview struct { /* unexported fields */ }` | Webview represents a connection to a Chrome DevTools Protocol endpoint. | Structural coverage in `TestWithTimeout_Good`, `TestWithConsoleLimit_Good`, and `TestAddConsoleMessage_Good`; no public-method test. |
| Function | `New` | `func New(opts ...Option) (*Webview, error)` | New creates a new Webview instance with the given options. | `TestNew_Bad_NoDebugURL`, `TestNew_Bad_InvalidDebugURL`. |
| Method | `Webview.ClearConsole` | `func (wv *Webview) ClearConsole()` | ClearConsole clears captured console messages. | None in `webview_test.go`. |
| Method | `Webview.Click` | `func (wv *Webview) Click(selector string) error` | Click clicks on an element matching the selector. | None in `webview_test.go`. |
| Method | `Webview.Close` | `func (wv *Webview) Close() error` | Close closes the Webview connection. | None in `webview_test.go`. |
| Method | `Webview.DragAndDrop` | `func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error` | DragAndDrop performs a drag and drop operation. | None in `webview_test.go`. |
| Method | `Webview.Evaluate` | `func (wv *Webview) Evaluate(script string) (any, error)` | Evaluate executes JavaScript and returns the result. | None in `webview_test.go`. |
| Method | `Webview.GetConsole` | `func (wv *Webview) GetConsole() []ConsoleMessage` | GetConsole returns captured console messages. | None in `webview_test.go`. |
| Method | `Webview.GetConsoleAll` | `func (wv *Webview) GetConsoleAll() iter.Seq[ConsoleMessage]` | GetConsoleAll returns an iterator over captured console messages. | None in `webview_test.go`. |
| Method | `Webview.GetHTML` | `func (wv *Webview) GetHTML(selector string) (string, error)` | GetHTML returns the outer HTML of an element or the whole document. | None in `webview_test.go`. |
| Method | `Webview.GetTitle` | `func (wv *Webview) GetTitle() (string, error)` | GetTitle returns the current page title. | None in `webview_test.go`. |
| Method | `Webview.GetURL` | `func (wv *Webview) GetURL() (string, error)` | GetURL returns the current page URL. | None in `webview_test.go`. |
| Method | `Webview.GoBack` | `func (wv *Webview) GoBack() error` | GoBack navigates back in history. | None in `webview_test.go`. |
| Method | `Webview.GoForward` | `func (wv *Webview) GoForward() error` | GoForward navigates forward in history. | None in `webview_test.go`. |
| Method | `Webview.Navigate` | `func (wv *Webview) Navigate(url string) error` | Navigate navigates to the specified URL. | None in `webview_test.go`. |
| Method | `Webview.QuerySelector` | `func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error)` | QuerySelector finds an element by CSS selector and returns its information. | None in `webview_test.go`. |
| Method | `Webview.QuerySelectorAll` | `func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error)` | QuerySelectorAll finds all elements matching the selector. | None in `webview_test.go`. |
| Method | `Webview.QuerySelectorAllAll` | `func (wv *Webview) QuerySelectorAllAll(selector string) iter.Seq[*ElementInfo]` | QuerySelectorAllAll returns an iterator over all elements matching the selector. | None in `webview_test.go`. |
| Method | `Webview.Reload` | `func (wv *Webview) Reload() error` | Reload reloads the current page. | None in `webview_test.go`. |
| Method | `Webview.Screenshot` | `func (wv *Webview) Screenshot() ([]byte, error)` | Screenshot captures a screenshot and returns it as PNG bytes. | None in `webview_test.go`. |
| Method | `Webview.SetUserAgent` | `func (wv *Webview) SetUserAgent(userAgent string) error` | SetUserAgent sets the user agent string. | None in `webview_test.go`. |
| Method | `Webview.SetViewport` | `func (wv *Webview) SetViewport(width, height int) error` | SetViewport sets the viewport size. | None in `webview_test.go`. |
| Method | `Webview.Type` | `func (wv *Webview) Type(selector, text string) error` | Type types text into an element matching the selector. | None in `webview_test.go`. |
| Method | `Webview.UploadFile` | `func (wv *Webview) UploadFile(selector string, filePaths []string) error` | UploadFile uploads a file to a file input element. | None in `webview_test.go`. |
| Method | `Webview.WaitForSelector` | `func (wv *Webview) WaitForSelector(selector string) error` | WaitForSelector waits for an element matching the selector to appear. | None in `webview_test.go`. |

View file

@ -1,45 +1,46 @@
---
title: Architecture
description: Internals of go-webview -- CDP connection, message protocol, DOM queries, console capture, action system, and Angular helpers.
---
# Architecture # Architecture
Module: `forge.lthn.ai/core/go-webview` This document describes how `go-webview` works internally. It covers the CDP connection lifecycle, message protocol, DOM query mechanics, input simulation, console capture, the action system, Angular helpers, and thread safety.
## Overview ## High-Level Data Flow
go-webview is a Chrome DevTools Protocol (CDP) client for browser automation, testing, and scraping. It provides a high-level Go API over the low-level CDP WebSocket protocol, connecting to an externally managed Chrome or Chromium instance running with the remote debugging port enabled. ```
Application Code
|
v
Webview (high-level API: Navigate, Click, Type, Screenshot, ...)
|
v
CDPClient (WebSocket transport, message framing, event dispatch)
|
v
Chrome / Chromium (running with --remote-debugging-port=9222)
```
The package does not launch Chrome itself. The caller is responsible for starting a Chrome process with `--remote-debugging-port=9222` before constructing a `Webview`. The application interacts with `Webview` methods. Each method constructs a CDP command, passes it to `CDPClient.Call()`, which serialises it as JSON over a WebSocket connection to Chrome. Chrome processes the command and returns a JSON response. Events (console messages, exceptions, navigation state changes) flow in the opposite direction: Chrome pushes them over the WebSocket, the `CDPClient` read loop dispatches them to registered handlers.
---
## Package Structure
| File | Responsibility |
|------|---------------|
| `webview.go` | `Webview` struct, public API, navigation, DOM, screenshot, JS evaluation |
| `cdp.go` | `CDPClient` — WebSocket transport, message framing, event dispatch |
| `actions.go` | `Action` interface, concrete action types, `ActionSequence` builder |
| `console.go` | `ConsoleWatcher`, `ExceptionWatcher`, log formatting |
| `angular.go` | `AngularHelper` — SPA-specific helpers for Angular 2+ and AngularJS 1.x |
---
## CDP Connection ## CDP Connection
### Initialisation ### Initialisation
`NewCDPClient(debugURL string)` connects to Chrome's HTTP endpoint: `NewCDPClient(debugURL string)` connects to Chrome's HTTP endpoint in four steps:
1. Issues `GET {debugURL}/json` to retrieve the list of available targets (tabs/pages). 1. Issues `GET {debugURL}/json` to retrieve the list of available targets (tabs/pages).
2. Selects the first target with `type == "page"` that has a `webSocketDebuggerUrl`. 2. Selects the first target with `type == "page"` that has a `webSocketDebuggerUrl`.
3. If no page target exists, calls `GET {debugURL}/json/new` to create one. 3. If no page target exists, calls `GET {debugURL}/json/new` to create one.
4. Upgrades the connection to WebSocket using `github.com/gorilla/websocket`. 4. Upgrades the connection to WebSocket using `github.com/gorilla/websocket` and starts a background `readLoop` goroutine.
5. Starts a background `readLoop` goroutine on the connection.
### Message Protocol ### Message Protocol
CDP uses JSON-framed messages over WebSocket. The client distinguishes two message kinds: CDP uses JSON-framed messages over WebSocket. The client distinguishes two message kinds:
- **Commands** sent by the client with an integer `id`. Chrome responds with a matching `id` and a `result` or `error` field. - **Commands** -- sent by the client with an integer `id`. Chrome responds with a matching `id` and a `result` or `error` field.
- **Events** sent by Chrome without an `id`. They carry a `method` name and a `params` map. - **Events** -- sent by Chrome without an `id`. They carry a `method` name and a `params` map.
The `CDPClient` maintains a `pending` map of `id -> chan *cdpResponse`. When `Call()` sends a command it registers a channel, then blocks on that channel until the matching response arrives or the context expires. The `CDPClient` maintains a `pending` map of `id -> chan *cdpResponse`. When `Call()` sends a command it registers a channel, then blocks on that channel until the matching response arrives or the context expires.
@ -49,25 +50,42 @@ Events are dispatched to zero or more registered handlers via `OnEvent(method, h
``` ```
New(WithDebugURL(...)) New(WithDebugURL(...))
└── NewCDPClient(url) +-- NewCDPClient(url)
├── HTTP GET /json (target discovery) |-- HTTP GET /json (target discovery)
├── websocket.Dial(wsURL) (WebSocket upgrade) |-- websocket.Dial(wsURL) (WebSocket upgrade)
└── go readLoop() (background goroutine) +-- go readLoop() (background goroutine)
wv.Close() wv.Close()
└── cancel() (signals readLoop to stop) +-- cancel() (signals readLoop to stop)
└── CDPClient.Close() +-- CDPClient.Close()
├── <-done (waits for readLoop to finish) |-- <-done (waits for readLoop to finish)
└── conn.Close() (closes WebSocket) +-- conn.Close() (closes WebSocket)
``` ```
--- ## Key Types
## Webview Struct ### CDPClient
```go
type CDPClient struct {
conn *websocket.Conn
debugURL string
wsURL string
msgID atomic.Int64 // monotonic command ID
pending map[int64]chan *cdpResponse // awaiting responses
handlers map[string][]func(map[string]any) // event subscribers
ctx context.Context
cancel context.CancelFunc
done chan struct{}
}
```
The core transport layer. All WebSocket reads happen in the `readLoop` goroutine. All writes are serialised through a `sync.RWMutex`. The `pending` map and `handlers` map each have their own dedicated mutexes.
### Webview
```go ```go
type Webview struct { type Webview struct {
mu sync.RWMutex
client *CDPClient client *CDPClient
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
@ -77,40 +95,22 @@ type Webview struct {
} }
``` ```
`New()` accepts functional options: The high-level API surface. Constructed via `New()` with functional options. On construction, it enables three CDP domains -- `Runtime`, `Page`, and `DOM` -- and registers a handler for `Runtime.consoleAPICalled` events so console capture begins immediately.
| Option | Effect | ### ConsoleMessage
|--------|--------|
| `WithDebugURL(url)` | Required. Connects to Chrome at the given HTTP debug endpoint. |
| `WithTimeout(d)` | Overrides the default 30-second operation timeout. |
| `WithConsoleLimit(n)` | Maximum console messages to retain in memory (default 1000). |
On construction, `New()` enables three CDP domains — `Runtime`, `Page`, and `DOM` — and registers a handler for `Runtime.consoleAPICalled` events to begin console capture immediately. ```go
type ConsoleMessage struct {
Type string // log, warn, error, info, debug
Text string // message text
Timestamp time.Time
URL string // source URL
Line int // source line number
Column int // source column number
}
```
--- ### ElementInfo
## Navigation
`Navigate(url string) error` calls `Page.navigate` then polls `document.readyState` via `Runtime.evaluate` at 100 ms intervals until the value is `"complete"` or the context deadline is exceeded.
`Reload()`, `GoBack()`, and `GoForward()` follow the same pattern: issue a CDP command then call `waitForLoad`.
`waitForSelector(ctx, selector)` polls `document.querySelector(selector)` at 100 ms intervals.
---
## DOM Queries
DOM queries follow a two-step pattern:
1. Call `DOM.getDocument` to obtain the root node ID.
2. Call `DOM.querySelector` or `DOM.querySelectorAll` with that node ID and the CSS selector string.
For each matching node, `getElementInfo` calls:
- `DOM.describeNode` — tag name and attribute list (flat alternating key/value array)
- `DOM.getBoxModel` — bounding rectangle from the `content` quad
The returned `ElementInfo` carries:
```go ```go
type ElementInfo struct { type ElementInfo struct {
@ -123,7 +123,37 @@ type ElementInfo struct {
} }
``` ```
--- ### BoundingBox
```go
type BoundingBox struct {
X float64
Y float64
Width float64
Height float64
}
```
## Navigation
`Navigate(url string) error` calls `Page.navigate` then polls `document.readyState` via `Runtime.evaluate` at 100 ms intervals until the value is `"complete"` or the context deadline is exceeded.
`Reload()`, `GoBack()`, and `GoForward()` follow the same pattern: issue a CDP command then call `waitForLoad`.
`waitForSelector(ctx, selector)` polls `document.querySelector(selector)` at 100 ms intervals until the element exists or the context expires.
## DOM Queries
DOM queries follow a two-step pattern:
1. Call `DOM.getDocument` to obtain the root node ID.
2. Call `DOM.querySelector` or `DOM.querySelectorAll` with that node ID and the CSS selector string.
For each matching node, `getElementInfo` calls:
- `DOM.describeNode` -- tag name and attribute list (flat alternating key/value array)
- `DOM.getBoxModel` -- bounding rectangle from the `content` quad
`QuerySelectorAllAll(selector)` returns an `iter.Seq[*ElementInfo]` iterator for lazy consumption of results.
## Click and Type ## Click and Type
@ -137,8 +167,6 @@ type ElementInfo struct {
`PressKeyAction` handles named keys (Enter, Tab, Escape, Backspace, Delete, arrow keys, Home, End, Page Up, Page Down) by mapping them to their CDP virtual key codes and code strings. `PressKeyAction` handles named keys (Enter, Tab, Escape, Backspace, Delete, arrow keys, Home, End, Page Up, Page Down) by mapping them to their CDP virtual key codes and code strings.
---
## Console Capture ## Console Capture
Console capture is enabled in `New()` by subscribing to `Runtime.consoleAPICalled` events. Console capture is enabled in `New()` by subscribing to `Runtime.consoleAPICalled` events.
@ -148,43 +176,64 @@ Console capture is enabled in `New()` by subscribing to `Runtime.consoleAPICalle
The `Webview` itself accumulates messages in a slice guarded by `sync.RWMutex`. When the buffer reaches `consoleLimit`, the oldest 100 messages are dropped. The `Webview` itself accumulates messages in a slice guarded by `sync.RWMutex`. When the buffer reaches `consoleLimit`, the oldest 100 messages are dropped.
```go ```go
msgs := wv.GetConsole() // returns a copy msgs := wv.GetConsole() // returns a collected slice
wv.ClearConsole() wv.ClearConsole()
// Or iterate lazily
for msg := range wv.GetConsoleAll() {
fmt.Println(msg.Text)
}
``` ```
### ConsoleWatcher ### ConsoleWatcher
`ConsoleWatcher` (constructed via `NewConsoleWatcher(wv)`) registers its own handler on the same `Runtime.consoleAPICalled` event. It adds filtering and reactive capabilities: `ConsoleWatcher` (constructed via `NewConsoleWatcher(wv)`) registers its own handler on the same `Runtime.consoleAPICalled` event. It adds filtering and reactive capabilities:
- `AddFilter(ConsoleFilter)` — filter by message type and/or text pattern - `AddFilter(ConsoleFilter)` -- filter by message type and/or text pattern (substring match)
- `AddHandler(ConsoleHandler)` callback invoked for each incoming message (outside the write lock) - `AddHandler(ConsoleHandler)` -- callback invoked for each incoming message (outside the write lock)
- `WaitForMessage(ctx, filter)` blocks until a matching message arrives - `WaitForMessage(ctx, filter)` -- blocks until a matching message arrives
- `WaitForError(ctx)` convenience wrapper for `type == "error"` - `WaitForError(ctx)` -- convenience wrapper for `type == "error"`
- `Errors()`, `Warnings()`, `HasErrors()`, `ErrorCount()` - `Errors()`, `Warnings()`, `HasErrors()`, `ErrorCount()`
- `FilteredMessages()` / `FilteredMessagesAll()` -- returns messages matching all active filters
### ExceptionWatcher ### ExceptionWatcher
`ExceptionWatcher` subscribes to `Runtime.exceptionThrown` events and captures unhandled JavaScript exceptions with full stack traces. It exposes the same reactive pattern as `ConsoleWatcher`: `AddHandler`, `WaitForException`, `HasExceptions`. `ExceptionWatcher` subscribes to `Runtime.exceptionThrown` events and captures unhandled JavaScript exceptions with full stack traces:
--- ```go
type ExceptionInfo struct {
Text string
LineNumber int
ColumnNumber int
URL string
StackTrace string
Timestamp time.Time
}
```
It exposes the same reactive pattern as `ConsoleWatcher`: `AddHandler`, `WaitForException`, `HasExceptions`, `Count`.
### FormatConsoleOutput
The package-level `FormatConsoleOutput(messages)` function formats a slice of `ConsoleMessage` into human-readable lines with timestamp, level prefix (`[ERROR]`, `[WARN]`, `[INFO]`, `[DEBUG]`, `[LOG]`), and message text.
## Screenshots ## Screenshots
`Screenshot()` calls `Page.captureScreenshot` with `format: "png"`. Chrome returns the image as a base64-encoded string in the `data` field of the response. The method decodes this and returns raw PNG bytes. `Screenshot()` calls `Page.captureScreenshot` with `format: "png"`. Chrome returns the image as a base64-encoded string in the `data` field of the response. The method decodes this and returns raw PNG bytes.
---
## JavaScript Evaluation ## JavaScript Evaluation
`evaluate(ctx, script)` calls `Runtime.evaluate` with `returnByValue: true`. The result is extracted from `result.result.value`. If `result.exceptionDetails` is present, the error description is returned as a Go error. `evaluate(ctx, script)` calls `Runtime.evaluate` with `returnByValue: true`. The result is extracted from `result.result.value`. If `result.exceptionDetails` is present, the error description is returned as a Go error.
`Evaluate(script string) (any, error)` is the public wrapper that applies the default timeout. `Evaluate(script string) (any, error)` is the public wrapper that applies the default timeout.
`GetURL()` and `GetTitle()` are thin wrappers that evaluate `window.location.href` and `document.title` respectively. Convenience wrappers:
`GetHTML(selector string)` evaluates `outerHTML` on the matched element, or `document.documentElement.outerHTML` when the selector is empty. | Method | JavaScript evaluated |
|--------|---------------------|
--- | `GetURL()` | `window.location.href` |
| `GetTitle()` | `document.title` |
| `GetHTML(selector)` | `document.querySelector(selector)?.outerHTML` (or `document.documentElement.outerHTML` when selector is empty) |
## Action System ## Action System
@ -196,12 +245,36 @@ type Action interface {
} }
``` ```
Concrete action types cover: `Click`, `Type`, `Navigate`, `Wait`, `WaitForSelector`, `Scroll`, `ScrollIntoView`, `Focus`, `Blur`, `Clear`, `Select`, `Check`, `Hover`, `DoubleClick`, `RightClick`, `PressKey`, `SetAttribute`, `RemoveAttribute`, `SetValue`. ### Concrete Action Types
`ActionSequence` provides a fluent builder: | Type | Description |
|------|-------------|
| `ClickAction` | Click an element by CSS selector |
| `TypeAction` | Type text into a focused element |
| `NavigateAction` | Navigate to a URL and wait for load |
| `WaitAction` | Wait for a fixed duration |
| `WaitForSelectorAction` | Wait for an element to appear |
| `ScrollAction` | Scroll to absolute coordinates |
| `ScrollIntoViewAction` | Scroll an element into view smoothly |
| `FocusAction` | Focus an element |
| `BlurAction` | Remove focus from an element |
| `ClearAction` | Clear an input's value, firing `input` and `change` events |
| `SelectAction` | Select a value in a `<select>` element |
| `CheckAction` | Check or uncheck a checkbox |
| `HoverAction` | Hover over an element |
| `DoubleClickAction` | Double-click an element |
| `RightClickAction` | Right-click (context menu) an element |
| `PressKeyAction` | Press a named key (Enter, Tab, Escape, etc.) |
| `SetAttributeAction` | Set an HTML attribute on an element |
| `RemoveAttributeAction` | Remove an HTML attribute from an element |
| `SetValueAction` | Set an input's value, firing `input` and `change` events |
### ActionSequence
`ActionSequence` provides a fluent builder. Actions are executed sequentially; the first failure halts the sequence and returns the action index with the error.
```go ```go
err := NewActionSequence(). err := webview.NewActionSequence().
Navigate("https://example.com"). Navigate("https://example.com").
WaitForSelector("#login-form"). WaitForSelector("#login-form").
Type("#email", "user@example.com"). Type("#email", "user@example.com").
@ -210,23 +283,24 @@ err := NewActionSequence().
Execute(ctx, wv) Execute(ctx, wv)
``` ```
`Execute` runs actions sequentially and returns the index and error of the first failure.
### File Upload and Drag-and-Drop ### File Upload and Drag-and-Drop
`UploadFile(selector, filePaths)` uses `DOM.setFileInputFiles` on the node ID of the resolved file input element. These are methods on `Webview` rather than action types:
`DragAndDrop(sourceSelector, targetSelector)` dispatches `mousePressed`, `mouseMoved`, and `mouseReleased` events between the centre points of the two elements. - `UploadFile(selector, filePaths)` -- uses `DOM.setFileInputFiles` on the resolved file input node
- `DragAndDrop(sourceSelector, targetSelector)` -- dispatches `mousePressed`, `mouseMoved`, and `mouseReleased` events between the centre points of two elements
---
## Angular Helpers ## Angular Helpers
`AngularHelper` (constructed via `NewAngularHelper(wv)`) provides SPA-specific utilities. All methods accept the `AngularHelper.timeout` deadline (default 30 s). `AngularHelper` (constructed via `NewAngularHelper(wv)`) provides SPA-specific utilities for Angular 2+ applications. All methods use the helper's configurable timeout (default 30 seconds).
### Application Detection ### Application Detection
`isAngularApp` checks for Angular 2+ via `window.getAllAngularRootElements`, the `[ng-version]` attribute, or `window.ng.probe`. It also checks for AngularJS 1.x via `window.angular.element`. `isAngularApp` checks for Angular by probing:
- `window.getAllAngularRootElements` (Angular 2+)
- The `[ng-version]` attribute on DOM elements
- `window.ng.probe` (Angular debug utilities)
- `window.angular.element` (AngularJS 1.x)
### Zone.js Stability ### Zone.js Stability
@ -234,44 +308,45 @@ err := NewActionSequence().
### Router Integration ### Router Integration
`NavigateByRouter(path)` obtains the `Router` service from the Angular injector and calls `router.navigateByUrl(path)`, then waits for Zone.js stability. - `NavigateByRouter(path)` -- obtains the `Router` service from the Angular injector, calls `router.navigateByUrl(path)`, then waits for Zone.js stability
- `GetRouterState()` -- returns an `AngularRouterState` with the current URL, fragment, route params, and query params
`GetRouterState()` returns an `AngularRouterState` with the current URL, fragment, route params, and query params.
### Component Introspection ### Component Introspection
`GetComponentProperty(selector, property)` and `SetComponentProperty(selector, property, value)` access component instances via `window.ng.probe(element).componentInstance`. After setting a property, `ApplicationRef.tick()` is called to trigger change detection. These methods require the Angular application to be running in debug mode (`window.ng.probe` must be available):
`CallComponentMethod(selector, method, args...)` invokes a method on the component instance and triggers change detection. - `GetComponentProperty(selector, property)` -- reads a property from a component instance
- `SetComponentProperty(selector, property, value)` -- writes a property and triggers `ApplicationRef.tick()`
- `CallComponentMethod(selector, method, args...)` -- invokes a method and triggers change detection
- `GetService(name)` -- retrieves a named service from the root injector, returned as a JSON-serialisable value
`GetService(name)` retrieves a named service from the root injector and returns a JSON-serialisable representation. ### ngModel Access
### ngModel - `GetNgModel(selector)` -- reads the current value of an ngModel-bound input
- `SetNgModel(selector, value)` -- writes the value, fires `input` and `change` events, and triggers `ApplicationRef.tick()`
`GetNgModel(selector)` reads the current value of an ngModel-bound input. `SetNgModel(selector, value)` writes the value, fires `input` and `change` events, and triggers `ApplicationRef.tick()`. ### Other Helpers
--- - `TriggerChangeDetection()` -- manually triggers `ApplicationRef.tick()` across all root elements
- `WaitForComponent(selector)` -- polls until a component instance exists on the matched element
- `DispatchEvent(selector, eventName, detail)` -- dispatches a `CustomEvent` on an element
## Multi-Tab Support ## Multi-Tab Support
`CDPClient.NewTab(url)` calls `GET {debugURL}/json/new?{url}` and returns a new `CDPClient` connected to the WebSocket of the newly created tab. Each tab has its own independent read loop and event handler registry, so console events and other notifications are tab-scoped. `CDPClient.NewTab(url)` calls `GET {debugURL}/json/new?{url}` and returns a new `CDPClient` connected to the WebSocket of the newly created tab. Each tab has its own independent read loop and event handler registry, so console events and other notifications are tab-scoped.
`CDPClient.CloseTab()` calls `Browser.close` on the tab's CDP session. `ListTargets(debugURL)` and `ListTargetsAll(debugURL)` are package-level utilities that query the HTTP endpoint without requiring an active WebSocket connection. `ListTargetsAll` returns an `iter.Seq[targetInfo]` iterator.
`ListTargets(debugURL)` and `GetVersion(debugURL)` are package-level utilities that query the HTTP endpoint without requiring an active WebSocket connection. `GetVersion(debugURL)` returns Chrome version information as a string map.
---
## Emulation ## Emulation
`SetViewport(width, height int)` calls `Emulation.setDeviceMetricsOverride` with `deviceScaleFactor: 1` and `mobile: false`. - `SetViewport(width, height int)` -- calls `Emulation.setDeviceMetricsOverride` with `deviceScaleFactor: 1` and `mobile: false`
- `SetUserAgent(ua string)` -- calls `Emulation.setUserAgentOverride`
`SetUserAgent(ua string)` calls `Emulation.setUserAgentOverride`.
---
## Thread Safety ## Thread Safety
- `CDPClient` uses `sync.RWMutex` for WebSocket writes and `sync.Mutex` for the pending-response map. Event handler registration uses a separate `sync.RWMutex`. - **CDPClient** uses `sync.RWMutex` for WebSocket writes and `sync.Mutex` for the pending-response map. Event handler registration uses a separate `sync.RWMutex`.
- `Webview` uses `sync.RWMutex` for its console log slice. - **Webview** uses `sync.RWMutex` for its console log slice.
- `ConsoleWatcher` and `ExceptionWatcher` use `sync.RWMutex` for their message and handler slices. Handlers are copied before being called so they execute outside the write lock. - **ConsoleWatcher** and **ExceptionWatcher** use `sync.RWMutex` for their message and handler slices. Handlers are copied before being called so they execute outside the write lock.
- Event handlers registered via `OnEvent` are dispatched in separate goroutines so they cannot block the WebSocket read loop.

View file

@ -0,0 +1,42 @@
# Convention Drift Audit
Date: 2026-03-23
Scope notes:
- `CLAUDE.md` reviewed.
- `CODEX.md` was not present anywhere under `/workspace`, so this audit is based on `CLAUDE.md` and the checked-in repository docs.
- `go test ./...` passes.
- `go test -coverprofile=webview.cover ./...` reports `16.1%` statement coverage.
- No source fixes were applied as part of this audit.
## `stdlib` -> `core.*`
- `docs/development.md:120` still tells contributors to wrap errors with `fmt.Errorf("context: %w", err)` so callers can use `errors.Is` and `errors.As`; `CLAUDE.md` now requires `coreerr.E("Scope.Method", "description", err)`. This is documentation drift rather than code drift.
## UK English
- `README.md:2` uses `License` in the badge alt text and badge label.
- `CONTRIBUTING.md:34` uses the US heading `License` instead of `Licence`.
- `docs/development.md:138` uses `licenced`; that is inconsistent with the repo's other licence/licensed wording.
- `webview.go:705` says `center coordinates` in a comment.
- `webview.go:718` says `center point` in a comment.
- `actions.go:511` says `center points` in a comment.
## Missing tests
- `actions.go:22`, `actions.go:33`, `actions.go:43`, `actions.go:74`, `actions.go:85`, `actions.go:97`, `actions.go:109`, `actions.go:121`, `actions.go:133`, `actions.go:153`, `actions.go:172`, `actions.go:189`, `actions.go:216`, `actions.go:263`, `actions.go:307`, `actions.go:378`, `actions.go:391`, `actions.go:404`, `actions.go:461`, `actions.go:471`, `actions.go:490` have no behavioural coverage. Existing action tests in `webview_test.go` only check field assignment and builder length, not execution paths.
- `angular.go:19`, `angular.go:27`, `angular.go:33`, `angular.go:41`, `angular.go:56`, `angular.go:93`, `angular.go:183`, `angular.go:214`, `angular.go:251`, `angular.go:331`, `angular.go:353`, `angular.go:384`, `angular.go:425`, `angular.go:453`, `angular.go:480`, `angular.go:517`, `angular.go:543`, `angular.go:570` are entirely uncovered. The Angular helper layer has no `_Good`, `_Bad`, or `_Ugly` behavioural tests.
- `cdp.go:78` is only lightly exercised by the invalid-debug-URL path; there is no success-path coverage for target discovery, tab creation, or WebSocket connection setup.
- `cdp.go:156`, `cdp.go:163`, `cdp.go:205`, `cdp.go:212`, `cdp.go:255`, `cdp.go:267`, `cdp.go:279`, `cdp.go:284`, `cdp.go:289`, `cdp.go:340`, `cdp.go:351`, `cdp.go:372`, `cdp.go:387` have no direct behavioural coverage for transport lifecycle, event dispatch, tab management, target enumeration, or version probing.
- `console.go:33`, `console.go:72`, `console.go:79`, `console.go:84`, `console.go:168`, `console.go:207`, `console.go:246`, `console.go:371`, `console.go:427`, `console.go:434`, `console.go:469` have no direct tests. The concurrency-sensitive watcher subscription, wait APIs, and event parsing paths are currently unverified.
- `webview.go:81` and `webview.go:110` are only partially covered; there is no success-path test for `WithDebugURL` plus `New` initialisation, including `Runtime.enable`, `Page.enable`, and `DOM.enable`.
- `webview.go:143`, `webview.go:152`, `webview.go:168`, `webview.go:176`, `webview.go:184`, `webview.go:192`, `webview.go:200`, `webview.go:219`, `webview.go:224`, `webview.go:238`, `webview.go:245`, `webview.go:272`, `webview.go:280`, `webview.go:288`, `webview.go:306`, `webview.go:324`, `webview.go:349`, `webview.go:363`, `webview.go:374`, `webview.go:387`, `webview.go:398`, `webview.go:422`, `webview.go:453`, `webview.go:495`, `webview.go:517`, `webview.go:541`, `webview.go:569`, `webview.go:604`, `webview.go:648`, `webview.go:704`, `webview.go:740` have no direct behavioural coverage across the main browser API, DOM lookup helpers, CDP evaluation path, and console capture path.
## SPDX headers
- `actions.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
- `angular.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
- `cdp.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
- `console.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
- `webview.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.
- `webview_test.go:1` is missing the required `// SPDX-License-Identifier: EUPL-1.2` header.

View file

@ -1,10 +1,15 @@
---
title: Development Guide
description: How to build, test, and contribute to go-webview -- prerequisites, test patterns, coding standards, and extension guides.
---
# Development Guide # Development Guide
## Prerequisites ## Prerequisites
### Go ### Go
Go 1.25 or later is required. The module path is `forge.lthn.ai/core/go-webview`. Go 1.26 or later is required. The module path is `dappco.re/go/core/webview`.
### Chrome or Chromium ### Chrome or Chromium
@ -38,8 +43,6 @@ The only runtime dependency is `github.com/gorilla/websocket v1.5.3`, declared i
go mod download go mod download
``` ```
---
## Build and Test ## Build and Test
### Running Tests ### Running Tests
@ -48,7 +51,7 @@ go mod download
go test ./... go test ./...
``` ```
Tests must pass before committing. There are currently no build tags that gate tests behind a Chrome connection; the integration tests in `webview_test.go` that require a live browser (`TestNew_Bad_InvalidDebugURL`) will fail gracefully because they assert that the error is non-nil when connecting to an unavailable port. Tests must pass before committing. The integration tests in `webview_test.go` that reference a live browser (`TestNew_Bad_InvalidDebugURL`) are designed to fail gracefully -- they assert that the error is non-nil when connecting to an unavailable port.
```bash ```bash
# Run a specific test # Run a specific test
@ -58,17 +61,24 @@ go test -v -run TestActionSequence_Good ./...
go test -v ./... go test -v ./...
``` ```
### Vetting and Formatting
```bash
gofmt -w .
go vet ./...
```
### Test Naming Convention ### Test Naming Convention
Tests follow the `_Good`, `_Bad`, `_Ugly` suffix pattern, consistent with the broader Core Go ecosystem: Tests follow the `_Good`, `_Bad`, `_Ugly` suffix pattern, consistent with the broader Core Go ecosystem:
- `_Good` — happy path, verifies correct behaviour under valid input. - `_Good` -- happy path, verifies correct behaviour under valid input.
- `_Bad` — expected error conditions, verifies that errors are returned and have the correct shape. - `_Bad` -- expected error conditions, verifies that errors are returned and have the correct shape.
- `_Ugly` — panic/edge cases, unexpected or degenerate inputs. - `_Ugly` -- panic/edge cases, unexpected or degenerate inputs.
All test functions use the standard `testing.T` interface; the project does not use a test framework. All test functions use the standard `testing.T` interface; the project does not use a test framework.
### CI Headless Tests ### Headless CI Tests
To add tests that exercise the full CDP stack in CI: To add tests that exercise the full CDP stack in CI:
@ -76,21 +86,19 @@ To add tests that exercise the full CDP stack in CI:
2. Serve test fixtures using `net/http/httptest` so tests do not depend on external URLs. 2. Serve test fixtures using `net/http/httptest` so tests do not depend on external URLs.
3. Use `WithTimeout` to set conservative deadlines appropriate for the CI environment. 3. Use `WithTimeout` to set conservative deadlines appropriate for the CI environment.
---
## Code Organisation ## Code Organisation
New source files belong in the root package (`package webview`). The package is intentionally a single flat package; do not create sub-packages. New source files belong in the root package (`package webview`). The package is intentionally a single flat package; do not create sub-packages.
Keep separation between layers: Keep separation between layers:
- **CDP transport**`cdp.go`. Do not put browser-level logic here. | Layer | File | Guidance |
- **High-level API**`webview.go`. Methods here should be safe to call from application code without CDP knowledge. |-------|------|----------|
- **Action types**`actions.go`. Add new action types here; keep each action focused on a single interaction. | CDP transport | `cdp.go` | Do not put browser-level logic here. |
- **Diagnostics**`console.go`. Console and exception capture live here. | High-level API | `webview.go` | Methods here should be safe to call from application code without CDP knowledge. |
- **SPA helpers**`angular.go`. Framework-specific helpers belong here or in a new file named after the framework (e.g. `react.go`, `vue.go`). | Action types | `actions.go` | Add new action types here; keep each action focused on a single interaction. |
| Diagnostics | `console.go` | Console and exception capture live here. |
--- | SPA helpers | `angular.go` | Framework-specific helpers belong here or in a new file named after the framework (e.g. `react.go`, `vue.go`). |
## Coding Standards ## Coding Standards
@ -129,8 +137,6 @@ Every Go source file must begin with:
The project is licenced under the European Union Public Licence 1.2 (EUPL-1.2). The project is licenced under the European Union Public Licence 1.2 (EUPL-1.2).
---
## Commit Guidelines ## Commit Guidelines
Use conventional commits: Use conventional commits:
@ -160,8 +166,6 @@ WaitForException API consistent with ConsoleWatcher.
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Virgil <virgil@lethean.io>
``` ```
---
## Adding a New Action Type ## Adding a New Action Type
1. Define a struct in `actions.go` with exported fields for the action's parameters. 1. Define a struct in `actions.go` with exported fields for the action's parameters.
@ -188,8 +192,6 @@ func (s *ActionSequence) Submit(selector string) *ActionSequence {
} }
``` ```
---
## Adding a New Angular Helper ## Adding a New Angular Helper
Add methods to `AngularHelper` in `angular.go`. Follow the established pattern: Add methods to `AngularHelper` in `angular.go`. Follow the established pattern:
@ -200,7 +202,14 @@ Add methods to `AngularHelper` in `angular.go`. Follow the established pattern:
4. After state-modifying operations, call `TriggerChangeDetection()` or inline `appRef.tick()`. 4. After state-modifying operations, call `TriggerChangeDetection()` or inline `appRef.tick()`.
5. For polling-based waits, use a `time.NewTicker` at 100 ms and select over `ctx.Done()`. 5. For polling-based waits, use a `time.NewTicker` at 100 ms and select over `ctx.Done()`.
--- ## Adding a New SPA Framework Helper
To add support for a different single-page application framework (e.g. React, Vue):
1. Create a new file named after the framework (e.g. `react.go`).
2. Define a helper struct that holds a `*Webview` reference and a configurable timeout.
3. Use `evaluate()` to inject JavaScript that probes framework-specific globals and APIs.
4. Follow the same `context.WithTimeout` + polling pattern established in `angular.go`.
## Forge Push ## Forge Push

View file

@ -2,7 +2,7 @@
## Origin ## Origin
go-webview was extracted from the `pkg/webview/` directory of `forge.lthn.ai/core/go` on 19 February 2026 by Virgil. The extraction made the package independently importable and gave it its own module path, dependency management, and commit history. go-webview was extracted from the `pkg/webview/` directory of `dappco.re/go/core` on 19 February 2026 by Virgil. The extraction made the package independently importable and gave it its own module path, dependency management, and commit history.
--- ---
@ -12,7 +12,7 @@ go-webview was extracted from the `pkg/webview/` directory of `forge.lthn.ai/cor
Commit `45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b` — 2026-02-19 Commit `45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b` — 2026-02-19
Initial extraction. All source files were moved from `go` `pkg/webview/` into the root of this repository. The module was renamed from the internal path to `forge.lthn.ai/core/go-webview`. Initial extraction. All source files were moved from `go` `pkg/webview/` into the root of this repository. The module was renamed from the internal path to `dappco.re/go/core/webview`.
Files established at extraction: Files established at extraction:

143
docs/index.md Normal file
View file

@ -0,0 +1,143 @@
---
title: go-webview
description: Chrome DevTools Protocol client for browser automation, testing, and scraping in Go.
---
# go-webview
`go-webview` is a Go package that provides browser automation via the Chrome DevTools Protocol (CDP). It connects to an externally managed Chrome or Chromium instance running with `--remote-debugging-port=9222` and exposes a high-level API for navigation, DOM queries, input simulation, screenshot capture, console monitoring, and JavaScript evaluation.
The package does not launch Chrome itself. The caller is responsible for starting the browser process before constructing a `Webview`.
**Module path:** `dappco.re/go/core/webview`
**Licence:** EUPL-1.2
**Go version:** 1.26+
**Dependencies:** `github.com/gorilla/websocket v1.5.3`
## Quick Start
Start Chrome with the remote debugging port enabled:
```bash
# macOS
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-port=9222
# Linux
google-chrome --remote-debugging-port=9222
# Headless (suitable for CI)
google-chrome --headless=new --remote-debugging-port=9222 --no-sandbox --disable-gpu
```
Then use the package in Go:
```go
import "dappco.re/go/core/webview"
// Connect to Chrome
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
if err != nil {
log.Fatal(err)
}
defer wv.Close()
// Navigate and interact
if err := wv.Navigate("https://example.com"); err != nil {
log.Fatal(err)
}
if err := wv.Click("#submit-button"); err != nil {
log.Fatal(err)
}
```
### Fluent Action Sequences
Chain multiple browser actions together with `ActionSequence`:
```go
err := webview.NewActionSequence().
Navigate("https://example.com").
WaitForSelector("#login-form").
Type("#email", "user@example.com").
Type("#password", "secret").
Click("#submit").
Execute(ctx, wv)
```
### Console Monitoring
Capture and filter browser console output:
```go
cw := webview.NewConsoleWatcher(wv)
cw.AddFilter(webview.ConsoleFilter{Type: "error"})
// ... perform browser actions ...
if cw.HasErrors() {
for _, msg := range cw.Errors() {
log.Printf("JS error: %s at %s:%d", msg.Text, msg.URL, msg.Line)
}
}
```
### Screenshots
Capture the current page as PNG:
```go
png, err := wv.Screenshot()
if err != nil {
log.Fatal(err)
}
os.WriteFile("screenshot.png", png, 0644)
```
### Angular Applications
First-class support for Angular single-page applications:
```go
ah := webview.NewAngularHelper(wv)
// Wait for Angular to stabilise
if err := ah.WaitForAngular(); err != nil {
log.Fatal(err)
}
// Navigate using Angular Router
if err := ah.NavigateByRouter("/dashboard"); err != nil {
log.Fatal(err)
}
// Inspect component state (debug mode only)
value, err := ah.GetComponentProperty("app-widget", "title")
```
## Package Layout
| File | Responsibility |
|------|----------------|
| `webview.go` | `Webview` struct, public API (navigate, click, type, screenshot, JS evaluation, DOM queries) |
| `cdp.go` | `CDPClient` -- WebSocket transport, CDP message framing, event dispatch, tab management |
| `actions.go` | `Action` interface, 19 concrete action types, `ActionSequence` fluent builder |
| `console.go` | `ConsoleWatcher`, `ExceptionWatcher`, console log formatting |
| `angular.go` | `AngularHelper` -- Zone.js stability, router navigation, component introspection, ngModel |
| `webview_test.go` | Unit tests for structs, options, and action building |
## Configuration Options
| Option | Default | Description |
|--------|---------|-------------|
| `WithDebugURL(url)` | *(required)* | Chrome DevTools HTTP debug endpoint, e.g. `http://localhost:9222` |
| `WithTimeout(d)` | 30 seconds | Default timeout for all browser operations |
| `WithConsoleLimit(n)` | 1000 | Maximum number of console messages retained in memory |
## Further Documentation
- [API Contract](api-contract.md) -- exported type, function, and method inventory with signatures and test coverage notes
- [Architecture](architecture.md) -- internals, data flow, CDP protocol, type reference
- [Development Guide](development.md) -- build, test, contribute, coding standards
- [Project History](history.md) -- extraction origin, completed phases, known limitations
- [Security Attack Vector Mapping](security-attack-vector-mapping.md) -- external input entry points, current validation, and attack-surface notes

View file

@ -0,0 +1,65 @@
# Security Attack Vector Mapping
Date: 2026-03-23
Notes:
- `CODEX.md` was not present in this repository when this mapping was prepared, so repo-specific conventions were taken from `CLAUDE.md`.
- Thin wrappers are grouped with the underlying sink when they share the same trust boundary and behaviour. Examples: `ActionSequence.Navigate` is grouped with `NavigateAction.Execute` and `Webview.Navigate`.
- This is a mapping document only. No mitigations or code changes are proposed here.
## Caller-Controlled Inputs
| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector |
|---|---|---|---|---|---|
| `WithDebugURL`, `NewCDPClient`, `ListTargets`, `ListTargetsAll`, `GetVersion` | `webview.go:81`<br>`cdp.go:78`<br>`cdp.go:351`<br>`cdp.go:372`<br>`cdp.go:387` | Caller-supplied Chrome debug URL | `http.Get(debugURL + "/json")`, `http.Get(debugURL + "/json/version")`, `json.Unmarshal`, and, in `NewCDPClient`, `websocket.DefaultDialer.Dial` to the returned `webSocketDebuggerUrl` | No scheme, host, auth, status-code, or body-size validation; JSON shape trusted after `json.Unmarshal` | SSRF against arbitrary internal hosts; unauthenticated trust in a hostile CDP endpoint; malicious `/json` can steer the code into a WS connection to an attacker host; large responses can cause memory pressure |
| `CDPClient.NewTab` | `cdp.go:289` | Caller-supplied URL for the new tab; remote `/json/new` response body | Raw string concatenation into `debugURL + "/json/new?" + url`, then `http.Get`, `json.Unmarshal`, and `websocket.DefaultDialer.Dial` to the returned WS URL | No URL escaping; no scheme or destination checks; no status-code or body-size validation | Query manipulation against the debug endpoint; opening attacker-chosen pages in the browser; SSRF through the debug service; hostile response can redirect the WS dial |
| `CDPClient.Call`, `CDPClient.Send` | `cdp.go:163`<br>`cdp.go:267` | Caller-supplied CDP method names and params | JSON serialisation to the live DevTools WebSocket | No allow-list or schema validation beyond JSON encoding | Arbitrary CDP command execution, including powerful browser control primitives; blind fire-and-forget misuse via `Send`; broader blast radius if an untrusted component can reach this API |
| `CDPClient.OnEvent` | `cdp.go:205` | Caller-supplied event names and callbacks | Stored in `handlers`, later invoked by `dispatchEvent` for browser-originated CDP events | No validation or deduplication | Unbounded handler registration; browser event floods can amplify into caller callback fan-out and goroutine pressure |
| `Webview.Navigate`, `NavigateAction.Execute`, `ActionSequence.Navigate` | `webview.go:152`<br>`actions.go:43`<br>`actions.go:446` | Caller-supplied navigation URL or action field | CDP `Page.navigate`, then `waitForLoad` polling via `Runtime.evaluate("document.readyState")` | No scheme, host, or destination validation | Browser-mediated SSRF to internal services; navigation to sensitive schemes such as `file:`, `data:`, `javascript:`, or others if Chrome permits; automation redirection into attacker-controlled flows |
| `Webview.Click`, `ClickAction.Execute`, `ActionSequence.Click` | `webview.go:168`<br>`webview.go:704`<br>`actions.go:22`<br>`actions.go:436` | Caller-supplied CSS selector or action field | `DOM.querySelector`; either CDP mouse events or JS fallback `document.querySelector(%q)?.click()` | Only existence and bounding-box checks; JS fallback uses `%q` for selector quoting | Expensive selector abuse against large DOMs; arbitrary interaction with attacker-chosen elements; destructive clicks inside a privileged browser session |
| `Webview.Type`, `TypeAction.Execute`, `ActionSequence.Type` | `webview.go:176`<br>`webview.go:740`<br>`actions.go:33`<br>`actions.go:441` | Caller-supplied selector and text | JS focus script, then `Input.dispatchKeyEvent` for each rune | Selector is JS-quoted with `%q`; text is unbounded | Arbitrary input injection into forms and widgets; credential stuffing into the current page; large payloads can generate high event volume |
| `Webview.QuerySelector` | `webview.go:184`<br>`webview.go:569` | Caller-supplied selector | `DOM.getDocument`, `DOM.querySelector`, `DOM.describeNode`, `DOM.getBoxModel`, then `ElementInfo` returned | No selector validation beyond CDP/browser parsing; result fields only type-asserted | DOM metadata exfiltration from an untrusted page; attacker-controlled attribute values returned to the caller; selector complexity abuse |
| `Webview.QuerySelectorAll`, `Webview.QuerySelectorAllAll` | `webview.go:192`<br>`webview.go:200`<br>`webview.go:604` | Caller-supplied selector | `DOM.querySelectorAll`, then `getElementInfo` per returned node | No selector validation beyond CDP/browser parsing; no cap on result count | Large node sets can amplify CPU and memory use; DOM data exfiltration; selector complexity abuse |
| `Webview.WaitForSelector`, `WaitForSelectorAction.Execute`, `ActionSequence.WaitForSelector` | `webview.go:280`<br>`webview.go:517`<br>`actions.go:74`<br>`actions.go:456` | Caller-supplied selector | Repeated `Runtime.evaluate("!!document.querySelector(%q)")` until timeout | Selector is JS-quoted with `%q`; no complexity or rate limits beyond the 100 ms ticker | Polling on hostile/large DOMs can create steady CPU load; attacker controls when the wait resolves |
| `Webview.Evaluate` | `webview.go:272`<br>`webview.go:541` | Caller-supplied JavaScript source | CDP `Runtime.evaluate` with `returnByValue: true`, result returned to caller | No validation; this surface is intentionally arbitrary | Direct arbitrary JS execution in the page; DOM/session data exfiltration; page mutation; leverage of any privileged browser APIs exposed to the page context |
| `Webview.GetHTML` | `webview.go:324` | Optional caller-supplied selector | Fixed or selector-based JS passed to `Runtime.evaluate`, HTML returned | Selector is JS-quoted with `%q`; no output size limit | Full-document or targeted DOM exfiltration; large HTML payloads can cause memory pressure; selector complexity abuse |
| `Webview.SetViewport` | `webview.go:349` | Caller-supplied width and height | CDP `Emulation.setDeviceMetricsOverride` | No range checks | Extreme dimensions can drive browser resource use or renderer instability |
| `Webview.SetUserAgent` | `webview.go:363` | Caller-supplied User-Agent string | CDP `Emulation.setUserAgentOverride` | No allow-list or content filtering in package code | Header spoofing, app feature-gating bypass, and downstream log pollution if Chrome accepts unusual characters |
| `Webview.UploadFile` | `actions.go:471` | Caller-supplied selector and local file paths | `DOM.setFileInputFiles` | Selector must resolve; file paths are not normalised, existence-checked, or restricted | Sensitive local file selection followed by browser-side upload or exfiltration if the page submits the form |
| `Webview.DragAndDrop` | `actions.go:490` | Caller-supplied source and target selectors | `querySelector` for both ends, then `Input.dispatchMouseEvent` sequence | Existence and bounding-box checks only | Arbitrary drag/drop interactions in a privileged session; selector complexity abuse |
| `ScrollAction.Execute` | `actions.go:85` | Caller-populated X/Y values | Raw JS `window.scrollTo(%d, %d)` via `Webview.evaluate` | Numeric formatting only | Large values can produce unexpected page behaviour; lower-risk than the arbitrary-script surface but still direct page control |
| `ScrollIntoViewAction.Execute`, `FocusAction.Execute`, `BlurAction.Execute`, `ClearAction.Execute`, `SelectAction.Execute`, `CheckAction.Execute`, `SetAttributeAction.Execute`, `RemoveAttributeAction.Execute`, `SetValueAction.Execute` | `actions.go:97`<br>`actions.go:109`<br>`actions.go:121`<br>`actions.go:133`<br>`actions.go:153`<br>`actions.go:172`<br>`actions.go:378`<br>`actions.go:391`<br>`actions.go:404` | Caller-populated selector, value, attribute, or checked-state fields | Constructed JS passed to `Webview.evaluate`; several rows also dispatch `input`/`change` events | String inputs are JS-quoted with `%q`; no semantic allow-list or size checks | Arbitrary DOM mutation and synthetic event dispatch; selector complexity abuse; low direct string-injection risk because `%q` quoting is used |
| `HoverAction.Execute`, `DoubleClickAction.Execute`, `RightClickAction.Execute` | `actions.go:189`<br>`actions.go:216`<br>`actions.go:263` | Caller-populated selectors | `querySelector` plus CDP mouse events, with JS fallbacks for double/right click | Existence and bounding-box checks; fallback selectors are JS-quoted with `%q` | Arbitrary pointer interaction, including double-click and context-menu behaviour inside a privileged session; selector complexity abuse |
| `PressKeyAction.Execute` | `actions.go:307` | Caller-populated key name or text | `Input.dispatchKeyEvent`; unknown keys are sent as raw `"text"` | Small allow-list for common keys; all other input is passed through | Synthetic keystroke injection, control-character delivery, and high-volume key event generation |
| `AngularHelper.NavigateByRouter` | `angular.go:214` | Caller-supplied Angular router path | JS `router.navigateByUrl(%q)` followed by Zone stability wait | Path is JS-quoted with `%q`; no route allow-list | Route manipulation inside a privileged SPA session; app-specific workflow or authorisation bypass if an untrusted caller controls the path |
| `AngularHelper.GetComponentProperty` | `angular.go:331` | Caller-supplied selector and property name | JS querySelector, `window.ng.probe(element).componentInstance`, then `component[%q]` returned | Selector in `querySelector` and property name are quoted, but selector is also interpolated raw into an error string with `%s` | Arbitrary component state read; JS injection if a crafted selector forces the error path and breaks out of the raw error string |
| `AngularHelper.SetComponentProperty` | `angular.go:353` | Caller-supplied selector, property name, and value | JS querySelector, `component[%q] = %v`, then `ApplicationRef.tick()` | Property name is quoted; selector also appears raw in an error string; `formatJSValue` only safely quotes strings, bools, and `nil`, and uses raw `%v` otherwise | Arbitrary component state mutation; JS injection via the raw selector error path or via crafted non-primitive values rendered with raw `%v` |
| `AngularHelper.CallComponentMethod` | `angular.go:384` | Caller-supplied selector, method name, and args | JS querySelector, `component[%q](%s)`, then `ApplicationRef.tick()` | Method name is quoted at call time but also appears raw in an error string; args use `formatJSValue` | Arbitrary component method invocation; JS injection via selector/method-name error paths or crafted args rendered with raw `%v` |
| `AngularHelper.GetService` | `angular.go:453` | Caller-supplied Angular DI token name | JS `injector.get(%q)` followed by `JSON.stringify/parse`, returned to caller | Service name is JS-quoted; no size or content limits on serialised output | Exfiltration of DI service state from debug-enabled Angular apps; large services can cause serialisation or memory pressure |
| `AngularHelper.WaitForComponent` | `angular.go:480` | Caller-supplied selector | Repeated JS querySelector plus `window.ng.probe` until timeout | Selector is JS-quoted with `%q` | Polling on hostile DOMs can create steady CPU load; attacker controls when the wait resolves |
| `AngularHelper.DispatchEvent` | `angular.go:517` | Caller-supplied selector, event name, and detail payload | JS `new CustomEvent(%q, { bubbles: true, detail: %s })`, then `dispatchEvent` | Event name is quoted; selector also appears raw in an error string; `detail` uses `formatJSValue` | Synthetic event injection into Angular app logic; JS injection via the raw selector error path or crafted detail rendered with raw `%v` |
| `AngularHelper.GetNgModel` | `angular.go:543` | Caller-supplied selector | JS querySelector, optional Angular debug probe, value/text returned to caller | Selector is JS-quoted with `%q` | Exfiltration of form or model values from the current page |
| `AngularHelper.SetNgModel` | `angular.go:570` | Caller-supplied selector and value | JS `element.value = %v`, `input`/`change` events, and `ApplicationRef.tick()` | Selector also appears raw in an error string; value uses `formatJSValue` | Arbitrary model mutation; business-logic and event injection; JS injection via raw selector error path or crafted value rendered with raw `%v` |
| `ConsoleWatcher.WaitForMessage` | `console.go:168` | Caller-supplied filter pattern plus browser-originated console text | Substring scans over stored and future console messages | No pattern-length cap or escaping | Large attacker-controlled log lines combined with long caller-supplied patterns can amplify CPU use; hostile pages can control when the wait resolves |
| `FormatConsoleOutput` | `console.go:524` | Caller- or browser-supplied `ConsoleMessage` fields | Raw `fmt.Sprintf` into output lines | No sanitisation of text, URL, or prefix content | Log forging and terminal escape propagation if the formatted output is printed or persisted verbatim |
## Browser- and CDP-Originated Inputs
| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector |
|---|---|---|---|---|---|
| `CDPClient.readLoop` | `cdp.go:212` | Raw WebSocket frames from the connected CDP peer | `json.Unmarshal` into `cdpResponse` or `cdpEvent`, then pending response channels or `dispatchEvent` | No explicit frame-size limit, schema validation, origin check, or auth check; malformed frames are mostly ignored | Memory pressure from large frames; silent desynchronisation; spoofed responses/events from a hostile endpoint; event-flood delivery into higher layers |
| `CDPClient.dispatchEvent` | `cdp.go:255` | CDP event method and params forwarded from `readLoop` | One goroutine per registered handler | Clones the handler slice but does not rate-limit or bound concurrency | Goroutine exhaustion and scheduler pressure under high-volume event streams |
| `Webview.Screenshot` | `webview.go:245` | Browser-supplied base64 screenshot payload | Base64 decode into a byte slice returned to caller | Type assertion and base64 decode only; no size cap | Large screenshot payloads can cause memory pressure or decode-time DoS |
| `Webview.handleConsoleEvent` | `webview.go:453` | `Runtime.consoleAPICalled` event params from the page via CDP | Builds `ConsoleMessage` and appends it to the Webview ring buffer | Best-effort type assertions only; no sanitisation of text, URL, or stack data | Log forging, terminal escape propagation, and bounded memory pressure up to `consoleLimit` |
| `NewConsoleWatcher`, `ConsoleWatcher.handleConsoleEvent` | `console.go:33`<br>`console.go:246` | `Runtime.consoleAPICalled` event params from the page via CDP | Builds `ConsoleMessage`, stores it in the watcher buffer, then notifies registered handlers | Best-effort type assertions only; bounded by `limit`; no sanitisation | Caller handler fan-out on attacker-controlled log data; bounded memory pressure; log forging |
| `NewExceptionWatcher`, `ExceptionWatcher.handleException` | `console.go:371`<br>`console.go:468` | `Runtime.exceptionThrown` event params from the page via CDP | Extracts exception text and stack trace, appends to `ew.exceptions`, then calls registered handlers | Best-effort type assertions only; no sanitisation; no retention limit | Unbounded memory growth under exception spam; attacker-controlled stack traces and text reaching caller sinks; handler fan-out DoS |
| `ExceptionWatcher.WaitForException` | `console.go:434` | Stored and future browser-originated exception data | Returns the latest `ExceptionInfo` to the caller | No validation beyond prior parsing | Attacker controls exception timing and payload content that may be logged or acted on by the caller |
| `Webview.GetURL`, `Webview.GetTitle` | `webview.go:288`<br>`webview.go:306` | Page-controlled `window.location.href` and `document.title` values | Fixed `Runtime.evaluate` calls returning strings to the caller | Only result type assertions | Low-volume data exfiltration from the current page; attacker controls returned strings |
| `AngularHelper.GetRouterState` | `angular.go:251` | Page-controlled Angular router state returned from `Runtime.evaluate` | Parsed into `AngularRouterState` and returned to caller | Type assertions on expected string and map fields only | Exfiltration of route params, query params, and fragments from the SPA; large values can increase memory use |
## Local Configuration Inputs That Amplify Exposure
| Function | File:line | Input source | What it flows into | Current validation | Potential attack vector |
|---|---|---|---|---|---|
| `WithTimeout`, `AngularHelper.SetTimeout`, `WaitAction.Execute`, `ActionSequence.Wait` | `webview.go:93`<br>`angular.go:27`<br>`actions.go:59`<br>`actions.go:451` | Caller-supplied durations | Context deadlines and `time.After` waits | No range checks | Excessively long values can pin goroutines and prolong exposure windows; zero or negative values can short-circuit synchronisation logic |
| `WithConsoleLimit`, `ConsoleWatcher.SetLimit` | `webview.go:102`<br>`console.go:72` | Caller-supplied message limits | In-memory retention size for console buffers | No lower or upper bound checks | Very large limits increase memory retention under noisy pages; low or negative values do not disable capture cleanly |

6
go.mod
View file

@ -1,5 +1,9 @@
module forge.lthn.ai/core/go-webview module dappco.re/go/core/webview
go 1.26.0 go 1.26.0
require github.com/gorilla/websocket v1.5.3 require github.com/gorilla/websocket v1.5.3
require dappco.re/go/core/log v0.1.0
require dappco.re/go/core v0.8.0-alpha.1

12
go.sum
View file

@ -1,2 +1,14 @@
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

505
specs/RFC.md Normal file
View file

@ -0,0 +1,505 @@
# webview, **Import:** `dappco.re/go/core/webview`, **Files:** 5
## Types
### Action
Declaration: `type Action interface`
Browser action contract used by `ActionSequence`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Runs the action against the supplied `Webview` and caller-owned context.
### ActionSequence
Declaration: `type ActionSequence struct`
Represents an ordered list of `Action` values. All fields are unexported.
Methods:
- `Add(action Action) *ActionSequence`: Appends `action` to the sequence and returns the same sequence for chaining.
- `Click(selector string) *ActionSequence`: Appends `ClickAction{Selector: selector}`.
- `Execute(ctx context.Context, wv *Webview) error`: Executes actions in insertion order. The first failing action stops execution and is wrapped as `ActionSequence.Execute` with the zero-based action index.
- `Navigate(url string) *ActionSequence`: Appends `NavigateAction{URL: url}`.
- `Type(selector, text string) *ActionSequence`: Appends `TypeAction{Selector: selector, Text: text}`.
- `Wait(d time.Duration) *ActionSequence`: Appends `WaitAction{Duration: d}`.
- `WaitForSelector(selector string) *ActionSequence`: Appends `WaitForSelectorAction{Selector: selector}`.
### AngularHelper
Declaration: `type AngularHelper struct`
Angular-specific helper bound to a `Webview`. All fields are unexported. The helper stores the target `Webview` and a per-operation timeout, which defaults to 30 seconds in `NewAngularHelper`.
Methods:
- `CallComponentMethod(selector, methodName string, args ...any) (any, error)`: Looks up the Angular component instance for `selector`, verifies that `methodName` is callable, invokes it with JSON-marshalled arguments, ticks `ApplicationRef` when available, and returns the evaluated result.
- `DispatchEvent(selector, eventName string, detail any) error`: Dispatches a bubbling `CustomEvent` on the matched element. `detail` is marshalled into the page script, or `null` when omitted.
- `GetComponentProperty(selector, propertyName string) (any, error)`: Returns `componentInstance[propertyName]` for the Angular component attached to the matched element.
- `GetNgModel(selector string) (any, error)`: Returns `element.value` for `input`, `select`, and `textarea` elements, otherwise `element.value || element.textContent`. Missing elements produce `nil`.
- `GetRouterState() (*AngularRouterState, error)`: Walks Angular root elements, resolves the first available Router, and returns its URL, fragment, root params, and root query params. Only string values are copied into the returned maps.
- `GetService(serviceName string) (any, error)`: Resolves a service from the first Angular root injector and returns `JSON.parse(JSON.stringify(service))`, so only JSON-serialisable data survives.
- `NavigateByRouter(path string) error`: Resolves Angular Router from a root injector, calls `navigateByUrl(path)`, and then waits for Zone.js stability.
- `SetComponentProperty(selector, propertyName string, value any) error`: Sets `componentInstance[propertyName]` and ticks `ApplicationRef` when available.
- `SetNgModel(selector string, value any) error`: Assigns `element.value`, dispatches bubbling `input` and `change` events, and then ticks `ApplicationRef` on the first Angular root that exposes it.
- `SetTimeout(d time.Duration)`: Replaces the helper's default timeout for later Angular operations.
- `TriggerChangeDetection() error`: Tries to tick `ApplicationRef` on the first Angular root. The method only reports script-evaluation failures; a `false` result from the script is ignored.
- `WaitForAngular() error`: Verifies that the page looks like an Angular application, then waits for Zone.js stability. It first tries an async Zone-based script and falls back to polling every 50 ms until the helper timeout expires.
- `WaitForComponent(selector string) error`: Polls every 100 ms until `selector` resolves to an element with an Angular `componentInstance`, or until the helper timeout expires.
### AngularRouterState
Declaration: `type AngularRouterState struct`
Represents the Angular Router state returned by `AngularHelper.GetRouterState`.
Fields:
- `URL string`: Current router URL.
- `Fragment string`: Current URL fragment when Angular reports one.
- `Params map[string]string`: Root route parameters copied from the router state.
- `QueryParams map[string]string`: Root query parameters copied from the router state.
### BlurAction
Declaration: `type BlurAction struct`
Removes focus from an element selected by CSS.
Fields:
- `Selector string`: CSS selector passed to `document.querySelector`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.blur()` through `wv.evaluate`.
### BoundingBox
Declaration: `type BoundingBox struct`
Represents element coordinates derived from `DOM.getBoxModel`.
Fields:
- `X float64`: Left edge of the content box.
- `Y float64`: Top edge of the content box.
- `Width float64`: Width computed from the first and second X coordinates in the CDP content quad.
- `Height float64`: Height computed from the first and third Y coordinates in the CDP content quad.
### CDPClient
Declaration: `type CDPClient struct`
Low-level Chrome DevTools Protocol client backed by a single WebSocket connection. All fields are unexported.
Methods:
- `Call(ctx context.Context, method string, params map[string]any) (map[string]any, error)`: Clones `params`, assigns a monotonically increasing message ID, writes the request, and waits for the matching CDP response, `ctx.Done()`, or client shutdown.
- `Close() error`: Cancels the client context, fails pending calls, closes the WebSocket, waits for the read loop to exit, and returns a wrapped close error only when the socket close itself fails with a non-terminal error.
- `CloseTab() error`: Extracts the current target ID from the WebSocket URL, calls `Target.closeTarget`, checks `success` when the field is present, and then closes the client.
- `DebugURL() string`: Returns the canonical debug HTTP URL stored on the client.
- `NewTab(url string) (*CDPClient, error)`: Creates a target via `/json/new`, validates the returned WebSocket debugger URL, dials it, and returns a new `CDPClient`.
- `OnEvent(method string, handler func(map[string]any))`: Registers a handler for a CDP event method name. Event dispatch clones the handler list and event params and invokes each handler in its own goroutine.
- `Send(method string, params map[string]any) error`: Clones `params` and writes a fire-and-forget CDP message without waiting for a response.
- `WebSocketURL() string`: Returns the target WebSocket URL currently in use.
### CheckAction
Declaration: `type CheckAction struct`
Synchronises a checkbox state by clicking when the current `checked` state differs from the requested value.
Fields:
- `Selector string`: CSS selector for the checkbox element.
- `Checked bool`: Desired checked state.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Evaluates a script that clicks the element only when `el.checked != Checked`.
### ClearAction
Declaration: `type ClearAction struct`
Clears the value of an input-like element.
Fields:
- `Selector string`: CSS selector for the target element.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Sets `el.value = ""` and dispatches bubbling `input` and `change` events when the element exists.
### ClickAction
Declaration: `type ClickAction struct`
Action wrapper for the `Webview` click implementation.
Fields:
- `Selector string`: CSS selector for the target element.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Delegates to `wv.click(ctx, Selector)`.
### ConsoleFilter
Declaration: `type ConsoleFilter struct`
Filter used by `ConsoleWatcher`.
Fields:
- `Type string`: Exact message-type match. The watcher compares it directly against `ConsoleMessage.Type`.
- `Pattern string`: Case-sensitive substring match against `ConsoleMessage.Text`.
### ConsoleHandler
Declaration: `type ConsoleHandler func(msg ConsoleMessage)`
Callback signature used by `ConsoleWatcher.AddHandler`.
### ConsoleMessage
Declaration: `type ConsoleMessage struct`
Represents one console message captured from `Runtime.consoleAPICalled`.
Fields:
- `Type string`: Console message type reported by CDP.
- `Text string`: Message text built by joining `args[*].value` with spaces.
- `Timestamp time.Time`: Local capture time assigned in Go with `time.Now()`.
- `URL string`: URL taken from the first stack-frame entry when present.
- `Line int`: Line number taken from the first stack-frame entry when present.
- `Column int`: Column number taken from the first stack-frame entry when present.
### ConsoleWatcher
Declaration: `type ConsoleWatcher struct`
Higher-level console log collector layered on top of `Webview`. All fields are unexported. New watchers start with an empty message buffer and a default limit of 1000 messages.
Methods:
- `AddFilter(filter ConsoleFilter)`: Appends a filter. When at least one filter exists, `FilteredMessages` and `FilteredMessagesAll` use OR semantics across filters.
- `AddHandler(handler ConsoleHandler)`: Registers a callback for future messages captured by this watcher.
- `Clear()`: Removes all stored messages.
- `ClearFilters()`: Removes every registered filter.
- `Count() int`: Returns the current number of stored messages.
- `ErrorCount() int`: Counts stored messages where `Type == "error"`.
- `Errors() []ConsoleMessage`: Returns a collected slice of error messages.
- `ErrorsAll() iter.Seq[ConsoleMessage]`: Returns an iterator that yields stored messages whose type is exactly `"error"`.
- `FilteredMessages() []ConsoleMessage`: Returns a collected slice of messages that match the current filter set, or all messages when no filters exist.
- `FilteredMessagesAll() iter.Seq[ConsoleMessage]`: Returns an iterator over the current filtered view.
- `HasErrors() bool`: Reports whether any stored message has `Type == "error"`.
- `Messages() []ConsoleMessage`: Returns a collected slice of all stored messages.
- `MessagesAll() iter.Seq[ConsoleMessage]`: Returns an iterator over the stored message buffer.
- `SetLimit(limit int)`: Replaces the retention limit used for future appends. Existing messages are only trimmed on later writes.
- `WaitForError(ctx context.Context) (*ConsoleMessage, error)`: Equivalent to `WaitForMessage(ctx, ConsoleFilter{Type: "error"})`.
- `WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error)`: Returns the first already-stored message that matches `filter`, otherwise registers a temporary handler and waits for the next matching message or `ctx.Done()`.
- `Warnings() []ConsoleMessage`: Returns a collected slice of messages whose type is exactly `"warning"`.
- `WarningsAll() iter.Seq[ConsoleMessage]`: Returns an iterator over stored messages whose type is exactly `"warning"`.
### DoubleClickAction
Declaration: `type DoubleClickAction struct`
Double-click action for an element selected by CSS.
Fields:
- `Selector string`: CSS selector for the target element.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Uses a JavaScript `dblclick` fallback when the element has no bounding box; otherwise sends two `mousePressed` and `mouseReleased` pairs with increasing `clickCount` values at the element centre.
### ElementInfo
Declaration: `type ElementInfo struct`
Represents DOM metadata returned by `Webview.querySelector` and `Webview.querySelectorAll`.
Fields:
- `NodeID int`: CDP node ID used for later DOM operations.
- `TagName string`: `nodeName` returned by `DOM.describeNode`.
- `Attributes map[string]string`: Attributes parsed from the alternating key/value array returned by CDP.
- `InnerHTML string`: Declared field for inner HTML content. The current implementation does not populate it.
- `InnerText string`: Declared field for inner text content. The current implementation does not populate it.
- `BoundingBox *BoundingBox`: Element box derived from `DOM.getBoxModel`. It is `nil` when box lookup fails.
### ExceptionInfo
Declaration: `type ExceptionInfo struct`
Represents one `Runtime.exceptionThrown` event captured by `ExceptionWatcher`.
Fields:
- `Text string`: Exception text, overridden by `exception.description` when CDP provides one.
- `LineNumber int`: Line number reported by CDP.
- `ColumnNumber int`: Column number reported by CDP.
- `URL string`: Source URL reported by CDP.
- `StackTrace string`: Stack trace formatted in Go as one `at function (url:line:column)` line per call frame.
- `Timestamp time.Time`: Local capture time assigned in Go with `time.Now()`.
### ExceptionWatcher
Declaration: `type ExceptionWatcher struct`
Collector for JavaScript exceptions emitted by the bound `Webview`. All fields are unexported.
Methods:
- `AddHandler(handler func(ExceptionInfo))`: Registers a callback for future exception events.
- `Clear()`: Removes all stored exceptions.
- `Count() int`: Returns the current number of stored exceptions.
- `Exceptions() []ExceptionInfo`: Returns a collected slice of all stored exceptions.
- `ExceptionsAll() iter.Seq[ExceptionInfo]`: Returns an iterator over the stored exception buffer.
- `HasExceptions() bool`: Reports whether any exceptions have been captured.
- `WaitForException(ctx context.Context) (*ExceptionInfo, error)`: Returns the most recently stored exception immediately when one already exists; otherwise registers a temporary handler and waits for the next exception or `ctx.Done()`.
### FocusAction
Declaration: `type FocusAction struct`
Moves focus to an element selected by CSS.
Fields:
- `Selector string`: CSS selector passed to `document.querySelector`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.focus()` through `wv.evaluate`.
### HoverAction
Declaration: `type HoverAction struct`
Moves the mouse pointer over an element.
Fields:
- `Selector string`: CSS selector for the target element.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Looks up the element, requires a non-nil bounding box, computes the box centre, and sends one `Input.dispatchMouseEvent` call with `type: "mouseMoved"`.
### NavigateAction
Declaration: `type NavigateAction struct`
Action wrapper for page navigation.
Fields:
- `URL string`: URL passed to `Page.navigate`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Calls `Page.navigate` and then waits until `document.readyState == "complete"`.
### Option
Declaration: `type Option func(*Webview) error`
Functional configuration hook applied by `New`. The exported option constructors are `WithDebugURL`, `WithTimeout`, and `WithConsoleLimit`.
### PressKeyAction
Declaration: `type PressKeyAction struct`
Key-press action implemented through `Input.dispatchKeyEvent`.
Fields:
- `Key string`: Key name or character to send.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Uses explicit CDP key metadata for common keys such as `Enter`, `Tab`, arrow keys, `Delete`, and paging keys. For any other string it sends a `keyDown` with `text: Key` followed by a bare `keyUp`.
### RemoveAttributeAction
Declaration: `type RemoveAttributeAction struct`
Removes a DOM attribute.
Fields:
- `Selector string`: CSS selector for the target element.
- `Attribute string`: Attribute name to remove.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.removeAttribute(Attribute)` through `wv.evaluate`.
### RightClickAction
Declaration: `type RightClickAction struct`
Context-click action for an element selected by CSS.
Fields:
- `Selector string`: CSS selector for the target element.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Uses a JavaScript `contextmenu` fallback when the element has no bounding box; otherwise sends `mousePressed` and `mouseReleased` with `button: "right"` at the element centre.
### ScrollAction
Declaration: `type ScrollAction struct`
Window scroll action.
Fields:
- `X int`: Horizontal scroll target passed to `window.scrollTo`.
- `Y int`: Vertical scroll target passed to `window.scrollTo`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Evaluates `window.scrollTo(X, Y)`.
### ScrollIntoViewAction
Declaration: `type ScrollIntoViewAction struct`
Scrolls a selected element into view.
Fields:
- `Selector string`: CSS selector for the target element.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Evaluates `document.querySelector(Selector)?.scrollIntoView({behavior: "smooth", block: "center"})`.
### SelectAction
Declaration: `type SelectAction struct`
Select-element value setter.
Fields:
- `Selector string`: CSS selector for the target element.
- `Value string`: Option value assigned to `el.value`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Sets `el.value` and dispatches a bubbling `change` event when the element exists.
### SetAttributeAction
Declaration: `type SetAttributeAction struct`
Sets a DOM attribute on the selected element.
Fields:
- `Selector string`: CSS selector for the target element.
- `Attribute string`: Attribute name to set.
- `Value string`: Attribute value passed to `setAttribute`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Runs `document.querySelector(Selector)?.setAttribute(Attribute, Value)` through `wv.evaluate`.
### SetValueAction
Declaration: `type SetValueAction struct`
Directly sets an input-like value.
Fields:
- `Selector string`: CSS selector for the target element.
- `Value string`: Value assigned to `el.value`.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Sets `el.value` and dispatches bubbling `input` and `change` events when the element exists.
### TargetInfo
Declaration: `type TargetInfo struct`
Represents one target entry returned by Chrome's `/json` and `/json/new` endpoints.
Fields:
- `ID string`: Chrome target ID.
- `Type string`: Target type such as `page`.
- `Title string`: Target title reported by Chrome.
- `URL string`: Target URL reported by Chrome.
- `WebSocketDebuggerURL string`: WebSocket debugger URL for the target.
### TypeAction
Declaration: `type TypeAction struct`
Action wrapper for `Webview` text entry.
Fields:
- `Selector string`: CSS selector for the target element.
- `Text string`: Text sent one rune at a time.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Delegates to `wv.typeText(ctx, Selector, Text)`.
### WaitAction
Declaration: `type WaitAction struct`
Time-based delay action.
Fields:
- `Duration time.Duration`: Delay length.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Waits for `Duration` with `time.After`, but returns `ctx.Err()` immediately if the context is cancelled first.
### WaitForSelectorAction
Declaration: `type WaitForSelectorAction struct`
Action wrapper for selector polling.
Fields:
- `Selector string`: CSS selector to wait for.
Methods:
- `Execute(ctx context.Context, wv *Webview) error`: Delegates to `wv.waitForSelector(ctx, Selector)`.
### Webview
Declaration: `type Webview struct`
High-level browser automation client built on `CDPClient`. All fields are unexported. Instances created by `New` carry a default timeout of 30 seconds and a default console retention limit of 1000 messages.
Methods:
- `ClearConsole()`: Removes all console messages stored on the `Webview`.
- `Click(selector string) error`: Creates a timeout-scoped context and delegates to the internal click path. The internal click logic queries the element, uses a JavaScript `.click()` fallback when there is no bounding box, and otherwise sends left-button press and release events at the element centre.
- `Close() error`: Cancels the `Webview` context and closes the underlying `CDPClient` when one exists.
- `DragAndDrop(sourceSelector, targetSelector string) error`: Looks up both elements, requires bounding boxes for both, then sends `mousePressed` at the source centre, `mouseMoved` to the target centre, and `mouseReleased` at the target centre.
- `Evaluate(script string) (any, error)`: Evaluates JavaScript through `Runtime.evaluate` with `returnByValue: true` and `awaitPromise: true`. When CDP reports `exceptionDetails`, the method returns a wrapped JavaScript error.
- `GetConsole() []ConsoleMessage`: Returns a collected slice of stored console messages.
- `GetConsoleAll() iter.Seq[ConsoleMessage]`: Returns an iterator over the stored console message buffer.
- `GetHTML(selector string) (string, error)`: Returns `document.documentElement.outerHTML` when `selector` is empty; otherwise returns `document.querySelector(selector)?.outerHTML || ""`.
- `GetTitle() (string, error)`: Evaluates `document.title` and requires the result to be a string.
- `GetURL() (string, error)`: Evaluates `window.location.href` and requires the result to be a string.
- `GoBack() error`: Calls `Page.goBackOrForward` with `delta: -1`.
- `GoForward() error`: Calls `Page.goBackOrForward` with `delta: 1`.
- `Navigate(url string) error`: Calls `Page.navigate` and then polls `document.readyState` every 100 ms until it becomes `"complete"` or the timeout expires.
- `QuerySelector(selector string) (*ElementInfo, error)`: Fetches the document root, runs `DOM.querySelector`, errors when the selector does not resolve, and returns `ElementInfo` for the matched node.
- `QuerySelectorAll(selector string) ([]*ElementInfo, error)`: Runs `DOM.querySelectorAll` and returns one `ElementInfo` per node ID whose metadata lookup succeeds. Nodes whose metadata fetch fails are skipped.
- `QuerySelectorAllAll(selector string) iter.Seq[*ElementInfo]`: Returns an iterator that runs `QuerySelectorAll` under the `Webview` timeout and yields each element. Errors produce an empty iterator.
- `Reload() error`: Calls `Page.reload` and then waits for `document.readyState == "complete"`.
- `Screenshot() ([]byte, error)`: Calls `Page.captureScreenshot` with `format: "png"`, decodes the returned base64 payload, and returns the PNG bytes.
- `SetUserAgent(userAgent string) error`: Calls `Emulation.setUserAgentOverride`.
- `SetViewport(width, height int) error`: Calls `Emulation.setDeviceMetricsOverride` with the supplied size, `deviceScaleFactor: 1`, and `mobile: false`.
- `Type(selector, text string) error`: Creates a timeout-scoped context and delegates to the internal typing path. The internal logic focuses the element with JavaScript, then sends one `keyDown` with `text` and one `keyUp` per rune in `text`.
- `UploadFile(selector string, filePaths []string) error`: Resolves the target node with `QuerySelector` and passes its node ID to `DOM.setFileInputFiles`.
- `WaitForSelector(selector string) error`: Polls `!!document.querySelector(selector)` every 100 ms until it becomes true or the timeout expires.
## Functions
### FormatConsoleOutput
`func FormatConsoleOutput(messages []ConsoleMessage) string`
Formats each message as `HH:MM:SS.mmm [PREFIX] text\n`. Prefixes are `[ERROR]` for `error`, `[WARN]` for `warning`, `[INFO]` for `info`, `[DEBUG]` for `debug`, and `[LOG]` for every other type.
### GetVersion
`func GetVersion(debugURL string) (map[string]string, error)`
Validates `debugURL`, requests `/json/version`, and decodes the response body into `map[string]string`.
### ListTargets
`func ListTargets(debugURL string) ([]TargetInfo, error)`
Validates `debugURL`, requests `/json`, and decodes the response body into a slice of `TargetInfo`.
### ListTargetsAll
`func ListTargetsAll(debugURL string) iter.Seq[TargetInfo]`
Iterator wrapper around `ListTargets`. When `ListTargets` fails, the iterator yields no values.
### New
`func New(opts ...Option) (*Webview, error)`
Creates a `Webview`, applies `opts` in order, requires an option that installs a `CDPClient`, enables the `Runtime`, `Page`, and `DOM` domains, and subscribes console capture. On any option or initialisation failure it cancels the context and closes the client when one was created.
### NewActionSequence
`func NewActionSequence() *ActionSequence`
Creates an empty `ActionSequence`.
### NewAngularHelper
`func NewAngularHelper(wv *Webview) *AngularHelper`
Creates an `AngularHelper` bound to `wv` with a 30-second default timeout.
### NewCDPClient
`func NewCDPClient(debugURL string) (*CDPClient, error)`
Validates that `debugURL` is an `http` or `https` DevTools root URL with no credentials, query, fragment, or non-root path. The function requests `/json`, picks the first `page` target with a debugger WebSocket URL, creates a new target via `/json/new` when none exists, validates that the WebSocket host matches the debug host, dials the socket, and starts the client's read loop.
### NewConsoleWatcher
`func NewConsoleWatcher(wv *Webview) *ConsoleWatcher`
Creates a `ConsoleWatcher`, initialises an empty message buffer with a 1000-message limit, and subscribes it to `Runtime.consoleAPICalled` events on `wv.client`.
### NewExceptionWatcher
`func NewExceptionWatcher(wv *Webview) *ExceptionWatcher`
Creates an `ExceptionWatcher`, initialises an empty exception buffer, and subscribes it to `Runtime.exceptionThrown` events on `wv.client`.
### WithConsoleLimit
`func WithConsoleLimit(limit int) Option`
Returns an `Option` that replaces `Webview.consoleLimit`. The default used by `New` is 1000.
### WithDebugURL
`func WithDebugURL(url string) Option`
Returns an `Option` that dials a `CDPClient` immediately and stores it on the `Webview`.
### WithTimeout
`func WithTimeout(d time.Duration) Option`
Returns an `Option` that replaces the default per-operation timeout used by `Webview` methods.

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: EUPL-1.2
// Package webview provides browser automation via Chrome DevTools Protocol (CDP). // Package webview provides browser automation via Chrome DevTools Protocol (CDP).
// //
// The package allows controlling Chrome/Chromium browsers for automated testing, // The package allows controlling Chrome/Chromium browsers for automated testing,
@ -24,12 +25,13 @@ package webview
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt"
"iter" "iter"
"slices" "slices"
"strings"
"sync" "sync"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// Webview represents a connection to a Chrome DevTools Protocol endpoint. // Webview represents a connection to a Chrome DevTools Protocol endpoint.
@ -80,7 +82,7 @@ func WithDebugURL(url string) Option {
return func(wv *Webview) error { return func(wv *Webview) error {
client, err := NewCDPClient(url) client, err := NewCDPClient(url)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to Chrome DevTools: %w", err) return coreerr.E("Webview.WithDebugURL", "failed to connect to Chrome DevTools", err)
} }
wv.client = client wv.client = client
return nil return nil
@ -112,26 +114,33 @@ func New(opts ...Option) (*Webview, error) {
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
timeout: 30 * time.Second, timeout: 30 * time.Second,
consoleLogs: make([]ConsoleMessage, 0, 100), consoleLogs: make([]ConsoleMessage, 0, 1000),
consoleLimit: 1000, consoleLimit: 1000,
} }
cleanupOnError := func() {
cancel()
if wv.client != nil {
_ = wv.client.Close()
}
}
for _, opt := range opts { for _, opt := range opts {
if err := opt(wv); err != nil { if err := opt(wv); err != nil {
cancel() cleanupOnError()
return nil, err return nil, err
} }
} }
if wv.client == nil { if wv.client == nil {
cancel() cancel()
return nil, fmt.Errorf("no debug URL provided; use WithDebugURL option") return nil, coreerr.E("Webview.New", "no debug URL provided; use WithDebugURL option", nil)
} }
// Enable console capture // Enable console capture
if err := wv.enableConsole(); err != nil { if err := wv.enableConsole(); err != nil {
cancel() cleanupOnError()
return nil, fmt.Errorf("failed to enable console capture: %w", err) return nil, coreerr.E("Webview.New", "failed to enable console capture", err)
} }
return wv, nil return wv, nil
@ -155,7 +164,7 @@ func (wv *Webview) Navigate(url string) error {
"url": url, "url": url,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to navigate: %w", err) return coreerr.E("Webview.Navigate", "failed to navigate", err)
} }
// Wait for page load // Wait for page load
@ -248,17 +257,17 @@ func (wv *Webview) Screenshot() ([]byte, error) {
"format": "png", "format": "png",
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to capture screenshot: %w", err) return nil, coreerr.E("Webview.Screenshot", "failed to capture screenshot", err)
} }
dataStr, ok := result["data"].(string) dataStr, ok := result["data"].(string)
if !ok { if !ok {
return nil, fmt.Errorf("invalid screenshot data") return nil, coreerr.E("Webview.Screenshot", "invalid screenshot data", nil)
} }
data, err := base64.StdEncoding.DecodeString(dataStr) data, err := base64.StdEncoding.DecodeString(dataStr)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode screenshot: %w", err) return nil, coreerr.E("Webview.Screenshot", "failed to decode screenshot", err)
} }
return data, nil return data, nil
@ -294,7 +303,7 @@ func (wv *Webview) GetURL() (string, error) {
url, ok := result.(string) url, ok := result.(string)
if !ok { if !ok {
return "", fmt.Errorf("invalid URL result") return "", coreerr.E("Webview.GetURL", "invalid URL result", nil)
} }
return url, nil return url, nil
@ -312,7 +321,7 @@ func (wv *Webview) GetTitle() (string, error) {
title, ok := result.(string) title, ok := result.(string)
if !ok { if !ok {
return "", fmt.Errorf("invalid title result") return "", coreerr.E("Webview.GetTitle", "invalid title result", nil)
} }
return title, nil return title, nil
@ -327,7 +336,7 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
if selector == "" { if selector == "" {
script = "document.documentElement.outerHTML" script = "document.documentElement.outerHTML"
} else { } else {
script = fmt.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector) script = core.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
} }
result, err := wv.evaluate(ctx, script) result, err := wv.evaluate(ctx, script)
@ -337,7 +346,7 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
html, ok := result.(string) html, ok := result.(string)
if !ok { if !ok {
return "", fmt.Errorf("invalid HTML result") return "", coreerr.E("Webview.GetHTML", "invalid HTML result", nil)
} }
return html, nil return html, nil
@ -375,7 +384,7 @@ func (wv *Webview) Reload() error {
_, err := wv.client.Call(ctx, "Page.reload", nil) _, err := wv.client.Call(ctx, "Page.reload", nil)
if err != nil { if err != nil {
return fmt.Errorf("failed to reload: %w", err) return coreerr.E("Webview.Reload", "failed to reload", err)
} }
return wv.waitForLoad(ctx) return wv.waitForLoad(ctx)
@ -410,7 +419,8 @@ func (wv *Webview) addConsoleMessage(msg ConsoleMessage) {
if len(wv.consoleLogs) >= wv.consoleLimit { if len(wv.consoleLogs) >= wv.consoleLimit {
// Remove oldest messages // Remove oldest messages
wv.consoleLogs = wv.consoleLogs[len(wv.consoleLogs)-wv.consoleLimit+100:] drop := min(100, len(wv.consoleLogs))
wv.consoleLogs = wv.consoleLogs[drop:]
} }
wv.consoleLogs = append(wv.consoleLogs, msg) wv.consoleLogs = append(wv.consoleLogs, msg)
} }
@ -452,14 +462,14 @@ func (wv *Webview) handleConsoleEvent(params map[string]any) {
// Extract args // Extract args
args, _ := params["args"].([]any) args, _ := params["args"].([]any)
var text strings.Builder text := core.NewBuilder()
for i, arg := range args { for i, arg := range args {
if argMap, ok := arg.(map[string]any); ok { if argMap, ok := arg.(map[string]any); ok {
if val, ok := argMap["value"]; ok { if val, ok := argMap["value"]; ok {
if i > 0 { if i > 0 {
text.WriteString(" ") text.WriteString(" ")
} }
text.WriteString(fmt.Sprint(val)) text.WriteString(core.Sprint(val))
} }
} }
} }
@ -515,7 +525,7 @@ func (wv *Webview) waitForSelector(ctx context.Context, selector string) error {
ticker := time.NewTicker(100 * time.Millisecond) ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
script := fmt.Sprintf("!!document.querySelector(%q)", selector) script := core.Sprintf("!!document.querySelector(%q)", selector)
for { for {
select { select {
@ -539,19 +549,20 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{ result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{
"expression": script, "expression": script,
"returnByValue": true, "returnByValue": true,
"awaitPromise": true,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to evaluate script: %w", err) return nil, coreerr.E("Webview.evaluate", "failed to evaluate script", err)
} }
// Check for exception // Check for exception
if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok { if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok {
if exception, ok := exceptionDetails["exception"].(map[string]any); ok { if exception, ok := exceptionDetails["exception"].(map[string]any); ok {
if description, ok := exception["description"].(string); ok { if description, ok := exception["description"].(string); ok {
return nil, fmt.Errorf("JavaScript error: %s", description) return nil, coreerr.E("Webview.evaluate", description, nil)
} }
} }
return nil, fmt.Errorf("JavaScript error") return nil, coreerr.E("Webview.evaluate", "JavaScript error", nil)
} }
// Extract result value // Extract result value
@ -567,17 +578,17 @@ func (wv *Webview) querySelector(ctx context.Context, selector string) (*Element
// Get document root // Get document root
docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil) docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get document: %w", err) return nil, coreerr.E("Webview.querySelector", "failed to get document", err)
} }
root, ok := docResult["root"].(map[string]any) root, ok := docResult["root"].(map[string]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid document root") return nil, coreerr.E("Webview.querySelector", "invalid document root", nil)
} }
rootID, ok := root["nodeId"].(float64) rootID, ok := root["nodeId"].(float64)
if !ok { if !ok {
return nil, fmt.Errorf("invalid root node ID") return nil, coreerr.E("Webview.querySelector", "invalid root node ID", nil)
} }
// Query selector // Query selector
@ -586,12 +597,12 @@ func (wv *Webview) querySelector(ctx context.Context, selector string) (*Element
"selector": selector, "selector": selector,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query selector: %w", err) return nil, coreerr.E("Webview.querySelector", "failed to query selector", err)
} }
nodeID, ok := queryResult["nodeId"].(float64) nodeID, ok := queryResult["nodeId"].(float64)
if !ok || nodeID == 0 { if !ok || nodeID == 0 {
return nil, fmt.Errorf("element not found: %s", selector) return nil, coreerr.E("Webview.querySelector", "element not found: "+selector, nil)
} }
return wv.getElementInfo(ctx, int(nodeID)) return wv.getElementInfo(ctx, int(nodeID))
@ -602,17 +613,17 @@ func (wv *Webview) querySelectorAll(ctx context.Context, selector string) ([]*El
// Get document root // Get document root
docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil) docResult, err := wv.client.Call(ctx, "DOM.getDocument", nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get document: %w", err) return nil, coreerr.E("Webview.querySelectorAll", "failed to get document", err)
} }
root, ok := docResult["root"].(map[string]any) root, ok := docResult["root"].(map[string]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid document root") return nil, coreerr.E("Webview.querySelectorAll", "invalid document root", nil)
} }
rootID, ok := root["nodeId"].(float64) rootID, ok := root["nodeId"].(float64)
if !ok { if !ok {
return nil, fmt.Errorf("invalid root node ID") return nil, coreerr.E("Webview.querySelectorAll", "invalid root node ID", nil)
} }
// Query selector all // Query selector all
@ -621,12 +632,12 @@ func (wv *Webview) querySelectorAll(ctx context.Context, selector string) ([]*El
"selector": selector, "selector": selector,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query selector all: %w", err) return nil, coreerr.E("Webview.querySelectorAll", "failed to query selector all", err)
} }
nodeIDs, ok := queryResult["nodeIds"].([]any) nodeIDs, ok := queryResult["nodeIds"].([]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid node IDs") return nil, coreerr.E("Webview.querySelectorAll", "invalid node IDs", nil)
} }
elements := make([]*ElementInfo, 0, len(nodeIDs)) elements := make([]*ElementInfo, 0, len(nodeIDs))
@ -653,7 +664,7 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
node, ok := descResult["node"].(map[string]any) node, ok := descResult["node"].(map[string]any)
if !ok { if !ok {
return nil, fmt.Errorf("invalid node description") return nil, coreerr.E("Webview.getElementInfo", "invalid node description", nil)
} }
tagName, _ := node["nodeName"].(string) tagName, _ := node["nodeName"].(string)
@ -668,6 +679,8 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
} }
} }
innerHTML, innerText := wv.getElementContent(ctx, nodeID)
// Get bounding box // Get bounding box
var box *BoundingBox var box *BoundingBox
if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{ if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{
@ -693,10 +706,61 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
NodeID: nodeID, NodeID: nodeID,
TagName: tagName, TagName: tagName,
Attributes: attrs, Attributes: attrs,
InnerHTML: innerHTML,
InnerText: innerText,
BoundingBox: box, BoundingBox: box,
}, nil }, nil
} }
// getElementContent retrieves the element's inner HTML and inner text.
func (wv *Webview) getElementContent(ctx context.Context, nodeID int) (string, string) {
resolveResult, err := wv.client.Call(ctx, "DOM.resolveNode", map[string]any{
"nodeId": nodeID,
})
if err != nil {
return "", ""
}
object, ok := resolveResult["object"].(map[string]any)
if !ok {
return "", ""
}
objectID, ok := object["objectId"].(string)
if !ok || objectID == "" {
return "", ""
}
callResult, err := wv.client.Call(ctx, "Runtime.callFunctionOn", map[string]any{
"objectId": objectID,
"functionDeclaration": "function() { return { innerHTML: this.innerHTML || '', innerText: this.innerText || '' }; }",
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", ""
}
return parseElementContent(callResult)
}
// parseElementContent extracts inner HTML and inner text from a CDP response.
func parseElementContent(result map[string]any) (string, string) {
resultObj, ok := result["result"].(map[string]any)
if !ok {
return "", ""
}
value, ok := resultObj["value"].(map[string]any)
if !ok {
return "", ""
}
innerHTML, _ := value["innerHTML"].(string)
innerText, _ := value["innerText"].(string)
return innerHTML, innerText
}
// click performs a click on an element. // click performs a click on an element.
func (wv *Webview) click(ctx context.Context, selector string) error { func (wv *Webview) click(ctx context.Context, selector string) error {
// Find element and get its center coordinates // Find element and get its center coordinates
@ -707,7 +771,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
// Fallback to JavaScript click // Fallback to JavaScript click
script := fmt.Sprintf("document.querySelector(%q)?.click()", selector) script := core.Sprintf("document.querySelector(%q)?.click()", selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -726,7 +790,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
"clickCount": 1, "clickCount": 1,
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to dispatch %s: %w", eventType, err) return coreerr.E("Webview.click", "failed to dispatch "+eventType, err)
} }
} }
@ -736,10 +800,10 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
// typeText types text into an element. // typeText types text into an element.
func (wv *Webview) typeText(ctx context.Context, selector, text string) error { func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
// Focus the element first // Focus the element first
script := fmt.Sprintf("document.querySelector(%q)?.focus()", selector) script := core.Sprintf("document.querySelector(%q)?.focus()", selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
if err != nil { if err != nil {
return fmt.Errorf("failed to focus element: %w", err) return coreerr.E("Webview.typeText", "failed to focus element", err)
} }
// Type each character // Type each character
@ -749,14 +813,14 @@ func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
"text": string(char), "text": string(char),
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to dispatch keyDown: %w", err) return coreerr.E("Webview.typeText", "failed to dispatch keyDown", err)
} }
_, err = wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{ _, err = wv.client.Call(ctx, "Input.dispatchKeyEvent", map[string]any{
"type": "keyUp", "type": "keyUp",
}) })
if err != nil { if err != nil {
return fmt.Errorf("failed to dispatch keyUp: %w", err) return coreerr.E("Webview.typeText", "failed to dispatch keyUp", err)
} }
} }

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context"
"testing" "testing"
"time" "time"
) )
@ -333,3 +335,453 @@ func TestScrollIntoViewAction_Good(t *testing.T) {
t.Errorf("Expected selector '#target', got %q", action.Selector) t.Errorf("Expected selector '#target', got %q", action.Selector)
} }
} }
// TestFormatConsoleOutput_Good verifies console output formatting.
func TestFormatConsoleOutput_Good(t *testing.T) {
ts := time.Date(2026, 1, 15, 14, 30, 45, 123000000, time.UTC)
messages := []ConsoleMessage{
{Type: "error", Text: "something broke", Timestamp: ts},
{Type: "warning", Text: "deprecated call", Timestamp: ts},
{Type: "info", Text: "loaded", Timestamp: ts},
{Type: "debug", Text: "trace data", Timestamp: ts},
{Type: "log", Text: "hello world", Timestamp: ts},
}
output := FormatConsoleOutput(messages)
expected := []string{
"14:30:45.123 [ERROR] something broke",
"14:30:45.123 [WARN] deprecated call",
"14:30:45.123 [INFO] loaded",
"14:30:45.123 [DEBUG] trace data",
"14:30:45.123 [LOG] hello world",
}
for _, exp := range expected {
if !containsString(output, exp) {
t.Errorf("Expected output to contain %q", exp)
}
}
}
// TestFormatConsoleOutput_Good_Empty verifies empty message list.
func TestFormatConsoleOutput_Good_Empty(t *testing.T) {
output := FormatConsoleOutput(nil)
if output != "" {
t.Errorf("Expected empty string, got %q", output)
}
}
// TestContainsString_Good verifies substring matching.
func TestContainsString_Good(t *testing.T) {
tests := []struct {
s, substr string
want bool
}{
{"hello world", "world", true},
{"hello world", "hello", true},
{"hello world", "xyz", false},
{"hello", "", true},
{"", "", true},
{"", "a", false},
{"abc", "abc", true},
{"abc", "abcd", false},
}
for _, tc := range tests {
got := containsString(tc.s, tc.substr)
if got != tc.want {
t.Errorf("containsString(%q, %q) = %v, want %v", tc.s, tc.substr, got, tc.want)
}
}
}
// TestFindString_Good verifies string search.
func TestFindString_Good(t *testing.T) {
tests := []struct {
s, substr string
want int
}{
{"hello world", "world", 6},
{"hello world", "hello", 0},
{"hello world", "xyz", -1},
{"abcabc", "abc", 0},
{"abc", "abc", 0},
}
for _, tc := range tests {
got := findString(tc.s, tc.substr)
if got != tc.want {
t.Errorf("findString(%q, %q) = %d, want %d", tc.s, tc.substr, got, tc.want)
}
}
}
// TestFormatJSValue_Good verifies JavaScript value formatting.
func TestFormatJSValue_Good(t *testing.T) {
tests := []struct {
input any
want string
}{
{"hello", `"hello"`},
{true, "true"},
{false, "false"},
{nil, "null"},
{42, "42"},
{3.14, "3.14"},
{map[string]any{"enabled": true}, `{"enabled":true}`},
{[]any{1, "two"}, `[1,"two"]`},
}
for _, tc := range tests {
got := formatJSValue(tc.input)
if got != tc.want {
t.Errorf("formatJSValue(%v) = %q, want %q", tc.input, got, tc.want)
}
}
}
// TestGetString_Good verifies map string extraction.
func TestGetString_Good(t *testing.T) {
m := map[string]any{
"name": "test",
"count": 42,
}
if got := getString(m, "name"); got != "test" {
t.Errorf("getString(m, 'name') = %q, want 'test'", got)
}
if got := getString(m, "count"); got != "" {
t.Errorf("getString(m, 'count') = %q, want empty (not a string)", got)
}
if got := getString(m, "missing"); got != "" {
t.Errorf("getString(m, 'missing') = %q, want empty", got)
}
}
// TestParseElementContent_Good verifies inner content extraction from CDP output.
func TestParseElementContent_Good(t *testing.T) {
result := map[string]any{
"result": map[string]any{
"value": map[string]any{
"innerHTML": "<span>Hello</span>",
"innerText": "Hello",
},
},
}
innerHTML, innerText := parseElementContent(result)
if innerHTML != "<span>Hello</span>" {
t.Fatalf("parseElementContent innerHTML = %q, want %q", innerHTML, "<span>Hello</span>")
}
if innerText != "Hello" {
t.Fatalf("parseElementContent innerText = %q, want %q", innerText, "Hello")
}
}
// TestWaitAction_Good_ContextCancelled verifies WaitAction respects context cancellation.
func TestWaitAction_Good_ContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
action := WaitAction{Duration: 10 * time.Second}
err := action.Execute(ctx, nil)
if err == nil {
t.Error("Expected context cancelled error")
}
}
// TestWaitAction_Good_ShortWait verifies WaitAction completes after duration.
func TestWaitAction_Good_ShortWait(t *testing.T) {
ctx := context.Background()
action := WaitAction{Duration: 10 * time.Millisecond}
start := time.Now()
err := action.Execute(ctx, nil)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if elapsed < 10*time.Millisecond {
t.Errorf("Expected at least 10ms elapsed, got %v", elapsed)
}
}
// TestAddConsoleMessage_Good verifies console message buffer management.
func TestAddConsoleMessage_Good(t *testing.T) {
wv := &Webview{
consoleLogs: make([]ConsoleMessage, 0, 10),
consoleLimit: 5,
}
// Add messages up to the limit
for i := range 6 {
wv.addConsoleMessage(ConsoleMessage{
Type: "log",
Text: time.Duration(i).String(),
})
}
// Buffer should have been trimmed
if len(wv.consoleLogs) > wv.consoleLimit {
t.Errorf("Expected at most %d messages, got %d", wv.consoleLimit, len(wv.consoleLogs))
}
}
// TestConsoleWatcherFilter_Good verifies console watcher filter matching.
func TestConsoleWatcherFilter_Good(t *testing.T) {
// Create a minimal ConsoleWatcher without a real Webview
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
// No filters — everything matches
msg := ConsoleMessage{Type: "error", Text: "test error"}
if !cw.matchesFilter(msg) {
t.Error("Expected message to match with no filters")
}
// Add type filter
cw.AddFilter(ConsoleFilter{Type: "error"})
if !cw.matchesFilter(msg) {
t.Error("Expected error message to match error filter")
}
logMsg := ConsoleMessage{Type: "log", Text: "test log"}
if cw.matchesFilter(logMsg) {
t.Error("Expected log message NOT to match error filter")
}
// Add pattern filter
cw.ClearFilters()
cw.AddFilter(ConsoleFilter{Pattern: "hello"})
helloMsg := ConsoleMessage{Type: "log", Text: "hello world"}
if !cw.matchesFilter(helloMsg) {
t.Error("Expected 'hello world' to match pattern 'hello'")
}
if cw.matchesFilter(msg) {
t.Error("Expected 'test error' NOT to match pattern 'hello'")
}
}
// TestConsoleWatcherCounts_Good verifies console watcher counting methods.
func TestConsoleWatcherCounts_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: []ConsoleMessage{
{Type: "log", Text: "info 1"},
{Type: "error", Text: "err 1"},
{Type: "log", Text: "info 2"},
{Type: "error", Text: "err 2"},
{Type: "warning", Text: "warn 1"},
},
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
if cw.Count() != 5 {
t.Errorf("Expected count 5, got %d", cw.Count())
}
if cw.ErrorCount() != 2 {
t.Errorf("Expected error count 2, got %d", cw.ErrorCount())
}
if !cw.HasErrors() {
t.Error("Expected HasErrors() to be true")
}
errors := cw.Errors()
if len(errors) != 2 {
t.Errorf("Expected 2 errors, got %d", len(errors))
}
warnings := cw.Warnings()
if len(warnings) != 1 {
t.Errorf("Expected 1 warning, got %d", len(warnings))
}
cw.Clear()
if cw.Count() != 0 {
t.Errorf("Expected count 0 after clear, got %d", cw.Count())
}
if cw.HasErrors() {
t.Error("Expected HasErrors() to be false after clear")
}
}
// TestExceptionWatcher_Good verifies exception watcher basic operations.
func TestExceptionWatcher_Good(t *testing.T) {
ew := &ExceptionWatcher{
exceptions: make([]ExceptionInfo, 0),
handlers: make([]exceptionHandlerRegistration, 0),
}
if ew.HasExceptions() {
t.Error("Expected no exceptions initially")
}
if ew.Count() != 0 {
t.Errorf("Expected count 0, got %d", ew.Count())
}
// Simulate adding an exception
ew.exceptions = append(ew.exceptions, ExceptionInfo{
Text: "TypeError: undefined is not a function",
LineNumber: 10,
URL: "https://example.com/app.js",
})
if !ew.HasExceptions() {
t.Error("Expected HasExceptions() to be true")
}
if ew.Count() != 1 {
t.Errorf("Expected count 1, got %d", ew.Count())
}
exceptions := ew.Exceptions()
if len(exceptions) != 1 {
t.Errorf("Expected 1 exception, got %d", len(exceptions))
}
if exceptions[0].Text != "TypeError: undefined is not a function" {
t.Errorf("Unexpected exception text: %q", exceptions[0].Text)
}
ew.Clear()
if ew.Count() != 0 {
t.Errorf("Expected count 0 after clear, got %d", ew.Count())
}
}
// TestAngularRouterState_Good verifies AngularRouterState struct.
func TestAngularRouterState_Good(t *testing.T) {
state := AngularRouterState{
URL: "/dashboard",
Fragment: "section1",
Params: map[string]string{"id": "123"},
QueryParams: map[string]string{
"tab": "settings",
},
}
if state.URL != "/dashboard" {
t.Errorf("Expected URL '/dashboard', got %q", state.URL)
}
if state.Fragment != "section1" {
t.Errorf("Expected fragment 'section1', got %q", state.Fragment)
}
if state.Params["id"] != "123" {
t.Errorf("Expected param id '123', got %q", state.Params["id"])
}
if state.QueryParams["tab"] != "settings" {
t.Errorf("Expected query param tab 'settings', got %q", state.QueryParams["tab"])
}
}
// TestTargetInfo_Good verifies TargetInfo struct.
func TestTargetInfo_Good(t *testing.T) {
target := TargetInfo{
ID: "ABC123",
Type: "page",
Title: "Example",
URL: "https://example.com",
WebSocketDebuggerURL: "ws://localhost:9222/devtools/page/ABC123",
}
if target.ID != "ABC123" {
t.Errorf("Expected ID 'ABC123', got %q", target.ID)
}
if target.Type != "page" {
t.Errorf("Expected type 'page', got %q", target.Type)
}
if target.WebSocketDebuggerURL == "" {
t.Error("Expected WebSocketDebuggerURL to be set")
}
}
// TestConsoleWatcherAddMessage_Good verifies message buffer limit enforcement.
func TestConsoleWatcherAddMessage_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 5,
handlers: make([]consoleHandlerRegistration, 0),
}
// Add messages past the limit
for i := range 7 {
cw.addMessage(ConsoleMessage{
Type: "log",
Text: time.Duration(i).String(),
})
}
if len(cw.messages) > cw.limit {
t.Errorf("Expected at most %d messages, got %d", cw.limit, len(cw.messages))
}
}
// TestConsoleWatcherHandler_Good verifies handlers are called for new messages.
func TestConsoleWatcherHandler_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
var received ConsoleMessage
cw.AddHandler(func(msg ConsoleMessage) {
received = msg
})
cw.addMessage(ConsoleMessage{Type: "error", Text: "handler test"})
if received.Text != "handler test" {
t.Errorf("Handler not called or wrong message: got %q", received.Text)
}
}
// TestConsoleWatcherFilteredMessages_Good verifies filtered message retrieval.
func TestConsoleWatcherFilteredMessages_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: []ConsoleMessage{
{Type: "log", Text: "info msg"},
{Type: "error", Text: "error msg"},
{Type: "log", Text: "another info"},
},
filters: []ConsoleFilter{{Type: "error"}},
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
filtered := cw.FilteredMessages()
if len(filtered) != 1 {
t.Fatalf("Expected 1 filtered message, got %d", len(filtered))
}
if filtered[0].Type != "error" {
t.Errorf("Expected error type, got %q", filtered[0].Type)
}
}
// TestExceptionInfo_Good verifies ExceptionInfo struct.
func TestExceptionInfo_Good(t *testing.T) {
info := ExceptionInfo{
Text: "ReferenceError: foo is not defined",
LineNumber: 42,
ColumnNumber: 10,
URL: "https://example.com/app.js",
StackTrace: " at bar (app.js:42:10)\n",
Timestamp: time.Now(),
}
if info.Text != "ReferenceError: foo is not defined" {
t.Errorf("Unexpected text: %q", info.Text)
}
if info.LineNumber != 42 {
t.Errorf("Expected line 42, got %d", info.LineNumber)
}
if info.StackTrace == "" {
t.Error("Expected stack trace to be set")
}
}