Compare commits

...

18 commits
v0.1.5 ... 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
18 changed files with 2596 additions and 350 deletions

View file

@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Module: `forge.lthn.ai/core/go-webview` — Chrome DevTools Protocol client for browser automation.
Module: `dappco.re/go/core/webview` — Chrome DevTools Protocol client for browser automation.
## Commands
@ -56,7 +56,7 @@ Key patterns:
- 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)
- Standard `testing.T` only — no test frameworks
- Wrap errors with `fmt.Errorf("context: %w", err)`
- 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
## Docs

View file

@ -1,4 +1,4 @@
[![Go Reference](https://pkg.go.dev/badge/forge.lthn.ai/core/go-webview.svg)](https://pkg.go.dev/forge.lthn.ai/core/go-webview)
[![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)
@ -6,14 +6,14 @@
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
**Language**: Go 1.25
## Quick Start
```go
import "forge.lthn.ai/core/go-webview"
import "dappco.re/go/core/webview"
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
defer wv.Close()
@ -34,6 +34,7 @@ err = webview.NewActionSequence().
## 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
- [Development Guide](docs/development.md) — prerequisites, build, test patterns, adding actions
- [Project History](docs/history.md) — completed phases, known limitations, future considerations

View file

@ -1,11 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"fmt"
"time"
coreerr "forge.lthn.ai/core/go-log"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// Action represents a browser action that can be performed.
@ -83,7 +84,7 @@ type ScrollAction struct {
// Execute performs the scroll action.
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)
return err
}
@ -95,7 +96,7 @@ type ScrollIntoViewAction struct {
// Execute scrolls the element into view.
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)
return err
}
@ -107,7 +108,7 @@ type FocusAction struct {
// Execute focuses the element.
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)
return err
}
@ -119,7 +120,7 @@ type BlurAction struct {
// Execute removes focus from the element.
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)
return err
}
@ -131,7 +132,7 @@ type ClearAction struct {
// Execute clears the input value.
func (a ClearAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(`
script := core.Sprintf(`
const el = document.querySelector(%q);
if (el) {
el.value = '';
@ -151,7 +152,7 @@ type SelectAction struct {
// Execute selects the option.
func (a SelectAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(`
script := core.Sprintf(`
const el = document.querySelector(%q);
if (el) {
el.value = %q;
@ -170,7 +171,7 @@ type CheckAction struct {
// Execute checks/unchecks the checkbox.
func (a CheckAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(`
script := core.Sprintf(`
const el = document.querySelector(%q);
if (el && el.checked !== %t) {
el.click();
@ -221,7 +222,7 @@ func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error {
if elem.BoundingBox == nil {
// Fallback to JavaScript
script := fmt.Sprintf(`
script := core.Sprintf(`
const el = document.querySelector(%q);
if (el) {
const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window});
@ -268,7 +269,7 @@ func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error {
if elem.BoundingBox == nil {
// Fallback to JavaScript
script := fmt.Sprintf(`
script := core.Sprintf(`
const el = document.querySelector(%q);
if (el) {
const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window});
@ -376,7 +377,7 @@ type SetAttributeAction struct {
// Execute sets the attribute.
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)
return err
}
@ -389,7 +390,7 @@ type RemoveAttributeAction struct {
// Execute removes the attribute.
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)
return err
}
@ -402,7 +403,7 @@ type SetValueAction struct {
// Execute sets the value.
func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(`
script := core.Sprintf(`
const el = document.querySelector(%q);
if (el) {
el.value = %q;
@ -461,7 +462,7 @@ func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence {
func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error {
for i, action := range s.actions {
if err := action.Execute(ctx, wv); err != nil {
return coreerr.E("ActionSequence.Execute", fmt.Sprintf("action %d failed", i), err)
return coreerr.E("ActionSequence.Execute", core.Sprintf("action %d failed", i), err)
}
}
return nil

View file

@ -1,12 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"fmt"
"strings"
"time"
coreerr "forge.lthn.ai/core/go-log"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// AngularHelper provides Angular-specific testing utilities.
@ -93,6 +93,21 @@ func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) {
func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
script := `
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
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
if (roots.length === 0) {
@ -121,28 +136,7 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
}
if (!zone) {
// Fallback: check window.Zone
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);
}
pollZone();
return;
}
@ -153,30 +147,28 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
}
// Wait for stability
const sub = zone.onStable.subscribe(() => {
sub.unsubscribe();
resolve(true);
});
// Timeout fallback
setTimeout(() => {
sub.unsubscribe();
resolve(zone.isStable);
}, 5000);
try {
const sub = zone.onStable.subscribe(() => {
sub.unsubscribe();
resolve(true);
});
} catch (e) {
pollZone();
}
})
`
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
// First evaluate the promise
_, err := ah.wv.evaluate(ctx, script)
result, err := ah.wv.evaluate(ctx, script)
if err != nil {
// If the script fails, fall back to simple polling
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.
@ -215,7 +207,7 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
script := core.Sprintf(`
(function() {
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
if (roots.length === 0) {
@ -332,19 +324,21 @@ func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (an
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) {
throw new Error('Element not found: %s');
}
const component = window.ng.probe(element).componentInstance;
if (!component) {
throw new Error('No Angular component found on element');
}
return component[%q];
})()
`, selector, selector, propertyName)
script := core.Sprintf(`
(function() {
const selector = %s;
const propertyName = %s;
const element = document.querySelector(selector);
if (!element) {
throw new Error('Element not found: ' + selector);
}
const component = window.ng.probe(element).componentInstance;
if (!component) {
throw new Error('No Angular component found on element');
}
return component[propertyName];
})()
`, formatJSValue(selector), formatJSValue(propertyName))
return ah.wv.evaluate(ctx, script)
}
@ -354,27 +348,29 @@ func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, val
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) {
throw new Error('Element not found: %s');
}
const component = window.ng.probe(element).componentInstance;
if (!component) {
throw new Error('No Angular component found on element');
}
component[%q] = %v;
script := core.Sprintf(`
(function() {
const selector = %s;
const propertyName = %s;
const element = document.querySelector(selector);
if (!element) {
throw new Error('Element not found: ' + selector);
}
const component = window.ng.probe(element).componentInstance;
if (!component) {
throw new Error('No Angular component found on element');
}
component[propertyName] = %s;
// Trigger change detection
const injector = window.ng.probe(element).injector;
const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef');
if (appRef) {
// Trigger change detection
const injector = window.ng.probe(element).injector;
const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef');
if (appRef) {
appRef.tick();
}
return true;
})()
`, selector, selector, propertyName, formatJSValue(value))
}
return true;
})()
`, formatJSValue(selector), formatJSValue(propertyName), formatJSValue(value))
_, err := ah.wv.evaluate(ctx, script)
return err
@ -385,7 +381,7 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
var argsStr strings.Builder
argsStr := core.NewBuilder()
for i, arg := range args {
if i > 0 {
argsStr.WriteString(", ")
@ -393,30 +389,32 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
argsStr.WriteString(formatJSValue(arg))
}
script := fmt.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) {
throw new Error('Element not found: %s');
}
const component = window.ng.probe(element).componentInstance;
if (!component) {
throw new Error('No Angular component found on element');
}
if (typeof component[%q] !== 'function') {
throw new Error('Method not found: %s');
}
const result = component[%q](%s);
script := core.Sprintf(`
(function() {
const selector = %s;
const methodName = %s;
const element = document.querySelector(selector);
if (!element) {
throw new Error('Element not found: ' + selector);
}
const component = window.ng.probe(element).componentInstance;
if (!component) {
throw new Error('No Angular component found on element');
}
if (typeof component[methodName] !== 'function') {
throw new Error('Method not found: ' + methodName);
}
const result = component[methodName](%s);
// Trigger change detection
const injector = window.ng.probe(element).injector;
const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef');
if (appRef) {
// Trigger change detection
const injector = window.ng.probe(element).injector;
const appRef = injector.get(window.ng.coreTokens.ApplicationRef || 'ApplicationRef');
if (appRef) {
appRef.tick();
}
return result;
})()
`, selector, selector, methodName, methodName, methodName, argsStr.String())
}
return result;
})()
`, formatJSValue(selector), formatJSValue(methodName), argsStr.String())
return ah.wv.evaluate(ctx, script)
}
@ -454,7 +452,7 @@ func (ah *AngularHelper) GetService(serviceName string) (any, error) {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
script := core.Sprintf(`
(function() {
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
for (const root of roots) {
@ -481,7 +479,7 @@ func (ah *AngularHelper) WaitForComponent(selector string) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
script := core.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) return false;
@ -523,17 +521,19 @@ func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) e
detailStr = formatJSValue(detail)
}
script := fmt.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) {
throw new Error('Element not found: %s');
}
const event = new CustomEvent(%q, { bubbles: true, detail: %s });
element.dispatchEvent(event);
return true;
})()
`, selector, selector, eventName, detailStr)
script := core.Sprintf(`
(function() {
const selector = %s;
const eventName = %s;
const element = document.querySelector(selector);
if (!element) {
throw new Error('Element not found: ' + selector);
}
const event = new CustomEvent(eventName, { bubbles: true, detail: %s });
element.dispatchEvent(event);
return true;
})()
`, formatJSValue(selector), formatJSValue(eventName), detailStr)
_, err := ah.wv.evaluate(ctx, script)
return err
@ -544,7 +544,7 @@ func (ah *AngularHelper) GetNgModel(selector string) (any, error) {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
script := core.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) return null;
@ -571,18 +571,19 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel()
script := fmt.Sprintf(`
(function() {
const element = document.querySelector(%q);
if (!element) {
throw new Error('Element not found: %s');
}
script := core.Sprintf(`
(function() {
const selector = %s;
const element = document.querySelector(selector);
if (!element) {
throw new Error('Element not found: ' + selector);
}
element.value = %v;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
element.value = %s;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
// Trigger change detection
// Trigger change detection
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
for (const root of roots) {
try {
@ -595,9 +596,9 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
} catch (e) {}
}
return true;
})()
`, selector, selector, formatJSValue(value))
return true;
})()
`, formatJSValue(selector), formatJSValue(value))
_, err := ah.wv.evaluate(ctx, script)
return err
@ -613,17 +614,15 @@ func getString(m map[string]any, key string) string {
}
func formatJSValue(v any) string {
switch val := v.(type) {
case string:
return fmt.Sprintf("%q", val)
case bool:
if val {
return "true"
}
return "false"
case nil:
return "null"
default:
return fmt.Sprintf("%v", val)
r := core.JSONMarshal(v)
if r.OK {
return string(r.Value.([]byte))
}
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))
}
}

494
cdp.go
View file

@ -1,26 +1,45 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"encoding/json"
"io"
"iter"
"net"
"net/http"
"net/url"
"path"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/gorilla/websocket"
)
coreerr "forge.lthn.ai/core/go-log"
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.
type CDPClient struct {
mu sync.RWMutex
conn *websocket.Conn
debugURL string
wsURL string
mu sync.RWMutex
conn *websocket.Conn
debugURL string
debugBase *url.URL
wsURL string
// Message tracking
msgID atomic.Int64
@ -32,9 +51,11 @@ type CDPClient struct {
handMu sync.RWMutex
// Lifecycle
ctx context.Context
cancel context.CancelFunc
done chan struct{}
ctx context.Context
cancel context.CancelFunc
done chan struct{}
closeOnce sync.Once
closeErr error
}
// cdpMessage represents a CDP protocol message.
@ -76,51 +97,41 @@ type TargetInfo struct {
// 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).
func NewCDPClient(debugURL string) (*CDPClient, error) {
// Get available targets
resp, err := http.Get(debugURL + "/json")
debugBase, err := parseDebugURL(debugURL)
if err != nil {
return nil, coreerr.E("CDPClient.New", "invalid debug URL", err)
}
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
defer cancel()
targets, err := listTargetsAt(ctx, debugBase)
if err != nil {
return nil, coreerr.E("CDPClient.New", "failed to get targets", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, coreerr.E("CDPClient.New", "failed to read targets", err)
}
var targets []TargetInfo
if err := json.Unmarshal(body, &targets); err != nil {
return nil, coreerr.E("CDPClient.New", "failed to parse targets", err)
}
// Find a page target
var wsURL string
for _, t := range targets {
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
}
}
if wsURL == "" {
// Try to create a new target
resp, err := http.Get(debugURL + "/json/new")
newTarget, err := createTargetAt(ctx, debugBase, "")
if err != nil {
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 {
return nil, coreerr.E("CDPClient.New", "failed to read new target", 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, coreerr.E("CDPClient.New", "failed to parse new target", err)
}
wsURL = newTarget.WebSocketDebuggerURL
}
if wsURL == "" {
@ -133,30 +144,17 @@ func NewCDPClient(debugURL string) (*CDPClient, error) {
return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err)
}
ctx, cancel := context.WithCancel(context.Background())
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
return newCDPClient(debugBase, wsURL, conn), nil
}
// Close closes the CDP connection.
func (c *CDPClient) Close() error {
c.cancel()
<-c.done // Wait for read loop to finish
return c.conn.Close()
c.close(errCDPClientClosed)
<-c.done
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.
@ -166,7 +164,7 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
msg := cdpMessage{
ID: id,
Method: method,
Params: params,
Params: cloneMapAny(params),
}
// Register response channel
@ -193,6 +191,8 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-c.ctx.Done():
return nil, coreerr.E("CDPClient.Call", "client closed", errCDPClientClosed)
case resp := <-respCh:
if resp.Error != nil {
return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil)
@ -213,31 +213,35 @@ func (c *CDPClient) readLoop() {
defer close(c.done)
for {
select {
case <-c.ctx.Done():
return
default:
}
_, data, err := c.conn.ReadMessage()
if err != nil {
// Check if context was cancelled
select {
case <-c.ctx.Done():
if c.ctx.Err() != nil {
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
}
c.close(err)
return
}
// Try to parse as response
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()
if ch, ok := c.pending[resp.ID]; ok {
respCopy := resp
ch <- &respCopy
select {
case ch <- &respCopy:
default:
}
}
c.pendMu.Unlock()
continue
@ -245,7 +249,7 @@ func (c *CDPClient) readLoop() {
// Try to parse as event
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)
}
}
@ -259,7 +263,8 @@ func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
for _, handler := range handlers {
// Call handler in goroutine to avoid blocking
go handler(params)
handlerParams := cloneMapAny(params)
go handler(handlerParams)
}
}
@ -267,7 +272,7 @@ func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
func (c *CDPClient) Send(method string, params map[string]any) error {
msg := cdpMessage{
Method: method,
Params: params,
Params: cloneMapAny(params),
}
c.mu.Lock()
@ -287,83 +292,70 @@ func (c *CDPClient) WebSocketURL() string {
// NewTab creates a new browser tab and returns a new CDPClient connected to it.
func (c *CDPClient) NewTab(url string) (*CDPClient, error) {
endpoint := c.debugURL + "/json/new"
if url != "" {
endpoint += "?" + url
}
ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
defer cancel()
resp, err := http.Get(endpoint)
target, err := createTargetAt(ctx, c.debugBase, url)
if err != nil {
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, coreerr.E("CDPClient.NewTab", "failed to read response", err)
}
var target TargetInfo
if err := json.Unmarshal(body, &target); err != nil {
return nil, coreerr.E("CDPClient.NewTab", "failed to parse target", err)
}
if target.WebSocketDebuggerURL == "" {
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
conn, _, err := websocket.DefaultDialer.Dial(target.WebSocketDebuggerURL, nil)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err)
}
ctx, cancel := context.WithCancel(context.Background())
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
return newCDPClient(c.debugBase, wsURL, conn), nil
}
// CloseTab closes the current tab (target).
func (c *CDPClient) CloseTab() error {
// Extract target ID from WebSocket URL
// Format: ws://host:port/devtools/page/TARGET_ID
// We'll use the Browser.close target API
targetID, err := targetIDFromWebSocketURL(c.wsURL)
if err != nil {
return coreerr.E("CDPClient.CloseTab", "failed to determine target ID", err)
}
ctx := context.Background()
_, err := c.Call(ctx, "Browser.close", nil)
return err
ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
defer cancel()
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.
func ListTargets(debugURL string) ([]TargetInfo, error) {
resp, err := http.Get(debugURL + "/json")
debugBase, err := parseDebugURL(debugURL)
if err != nil {
return nil, coreerr.E("ListTargets", "invalid debug URL", err)
}
ctx, cancel := context.WithTimeout(context.Background(), debugEndpointTimeout)
defer cancel()
targets, err := listTargetsAt(ctx, debugBase)
if err != nil {
return nil, coreerr.E("ListTargets", "failed to get targets", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, coreerr.E("ListTargets", "failed to read targets", err)
}
var targets []TargetInfo
if err := json.Unmarshal(body, &targets); err != nil {
return nil, coreerr.E("ListTargets", "failed to parse targets", err)
}
return targets, nil
}
@ -385,21 +377,261 @@ func ListTargetsAll(debugURL string) iter.Seq[TargetInfo] {
// GetVersion returns Chrome version information.
func GetVersion(debugURL string) (map[string]string, error) {
resp, err := http.Get(debugURL + "/json/version")
debugBase, err := parseDebugURL(debugURL)
if err != nil {
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() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, coreerr.E("GetVersion", "failed to read version", 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
if err := json.Unmarshal(body, &version); err != nil {
return nil, coreerr.E("GetVersion", "failed to parse version", err)
}
return version, nil
return body, nil
}
func listTargetsAt(ctx context.Context, debugBase *url.URL) ([]TargetInfo, error) {
body, err := doDebugRequest(ctx, debugBase, "/json", "")
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
import (
"context"
"fmt"
"iter"
"slices"
"strings"
"sync"
"sync/atomic"
"time"
core "dappco.re/go/core"
)
// ConsoleWatcher provides advanced console message watching capabilities.
type ConsoleWatcher struct {
mu sync.RWMutex
wv *Webview
messages []ConsoleMessage
filters []ConsoleFilter
limit int
handlers []ConsoleHandler
mu sync.RWMutex
wv *Webview
messages []ConsoleMessage
filters []ConsoleFilter
limit int
handlers []consoleHandlerRegistration
nextHandlerID atomic.Int64
}
// ConsoleFilter filters console messages.
@ -29,14 +32,19 @@ type ConsoleFilter struct {
// ConsoleHandler is called when a matching console message is received.
type ConsoleHandler func(msg ConsoleMessage)
type consoleHandlerRegistration struct {
id int64
handler ConsoleHandler
}
// NewConsoleWatcher creates a new console watcher for the webview.
func NewConsoleWatcher(wv *Webview) *ConsoleWatcher {
cw := &ConsoleWatcher{
wv: wv,
messages: make([]ConsoleMessage, 0, 100),
messages: make([]ConsoleMessage, 0, 1000),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]ConsoleHandler, 0),
handlers: make([]consoleHandlerRegistration, 0),
}
// Subscribe to console events from the webview's client
@ -63,9 +71,30 @@ func (cw *ConsoleWatcher) ClearFilters() {
// AddHandler adds a handler for console messages.
func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) {
cw.addHandler(handler)
}
func (cw *ConsoleWatcher) addHandler(handler ConsoleHandler) int64 {
cw.mu.Lock()
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.
@ -187,13 +216,8 @@ func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilt
}
}
cw.AddHandler(handler)
defer func() {
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()
}()
handlerID := cw.addHandler(handler)
defer cw.removeHandler(handlerID)
select {
case <-ctx.Done():
@ -248,14 +272,14 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
// Extract args
args, _ := params["args"].([]any)
var text strings.Builder
text := core.NewBuilder()
for i, arg := range args {
if argMap, ok := arg.(map[string]any); ok {
if val, ok := argMap["value"]; ok {
if i > 0 {
text.WriteString(" ")
}
text.WriteString(fmt.Sprint(val))
text.WriteString(core.Sprint(val))
}
}
}
@ -292,7 +316,8 @@ func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
// Enforce 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)
@ -301,8 +326,8 @@ func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
cw.mu.Unlock()
// Call handlers
for _, handler := range handlers {
handler(msg)
for _, registration := range handlers {
registration.handler(msg)
}
}
@ -360,10 +385,16 @@ type ExceptionInfo struct {
// ExceptionWatcher watches for JavaScript exceptions.
type ExceptionWatcher struct {
mu sync.RWMutex
wv *Webview
exceptions []ExceptionInfo
handlers []func(ExceptionInfo)
mu sync.RWMutex
wv *Webview
exceptions []ExceptionInfo
handlers []exceptionHandlerRegistration
nextHandlerID atomic.Int64
}
type exceptionHandlerRegistration struct {
id int64
handler func(ExceptionInfo)
}
// NewExceptionWatcher creates a new exception watcher.
@ -371,7 +402,7 @@ func NewExceptionWatcher(wv *Webview) *ExceptionWatcher {
ew := &ExceptionWatcher{
wv: wv,
exceptions: make([]ExceptionInfo, 0),
handlers: make([]func(ExceptionInfo), 0),
handlers: make([]exceptionHandlerRegistration, 0),
}
// Subscribe to exception events
@ -424,9 +455,30 @@ func (ew *ExceptionWatcher) Count() int {
// AddHandler adds a handler for exceptions.
func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) {
ew.addHandler(handler)
}
func (ew *ExceptionWatcher) addHandler(handler func(ExceptionInfo)) int64 {
ew.mu.Lock()
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.
@ -449,12 +501,8 @@ func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInf
}
}
ew.AddHandler(handler)
defer func() {
ew.mu.Lock()
ew.handlers = ew.handlers[:len(ew.handlers)-1]
ew.mu.Unlock()
}()
handlerID := ew.addHandler(handler)
defer ew.removeHandler(handlerID)
select {
case <-ctx.Done():
@ -477,7 +525,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
url, _ := exceptionDetails["url"].(string)
// Extract stack trace
var stackTrace strings.Builder
stackTrace := core.NewBuilder()
if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok {
if frames, ok := st["callFrames"].([]any); ok {
for _, f := range frames {
@ -486,7 +534,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
frameURL, _ := frame["url"].(string)
frameLine, _ := frame["lineNumber"].(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()
// Call handlers
for _, handler := range handlers {
handler(info)
for _, registration := range handlers {
registration.handler(info)
}
}
// FormatConsoleOutput formats console messages for display.
func FormatConsoleOutput(messages []ConsoleMessage) string {
var output strings.Builder
output := core.NewBuilder()
for _, msg := range messages {
prefix := ""
switch msg.Type {
@ -537,7 +585,7 @@ func FormatConsoleOutput(messages []ConsoleMessage) string {
prefix = "[LOG]"
}
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()
}

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

@ -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

@ -9,7 +9,7 @@ description: How to build, test, and contribute to go-webview -- prerequisites,
### Go
Go 1.26 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

View file

@ -2,7 +2,7 @@
## 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
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:

View file

@ -9,7 +9,7 @@ description: Chrome DevTools Protocol client for browser automation, testing, an
The package does not launch Chrome itself. The caller is responsible for starting the browser process before constructing a `Webview`.
**Module path:** `forge.lthn.ai/core/go-webview`
**Module path:** `dappco.re/go/core/webview`
**Licence:** EUPL-1.2
**Go version:** 1.26+
**Dependencies:** `github.com/gorilla/websocket v1.5.3`
@ -33,7 +33,7 @@ google-chrome --headless=new --remote-debugging-port=9222 --no-sandbox --disable
Then use the package in Go:
```go
import "forge.lthn.ai/core/go-webview"
import "dappco.re/go/core/webview"
// Connect to Chrome
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
@ -136,6 +136,8 @@ value, err := ah.GetComponentProperty("app-widget", "title")
## 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,7 +1,9 @@
module forge.lthn.ai/core/go-webview
module dappco.re/go/core/webview
go 1.26.0
require github.com/gorilla/websocket v1.5.3
require forge.lthn.ai/core/go-log v0.0.4
require dappco.re/go/core/log v0.1.0
require dappco.re/go/core v0.8.0-alpha.1

6
go.sum
View file

@ -1,5 +1,7 @@
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
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=

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).
//
// The package allows controlling Chrome/Chromium browsers for automated testing,
@ -24,14 +25,13 @@ package webview
import (
"context"
"encoding/base64"
"fmt"
"iter"
"slices"
"strings"
"sync"
"time"
coreerr "forge.lthn.ai/core/go-log"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// Webview represents a connection to a Chrome DevTools Protocol endpoint.
@ -114,13 +114,20 @@ func New(opts ...Option) (*Webview, error) {
ctx: ctx,
cancel: cancel,
timeout: 30 * time.Second,
consoleLogs: make([]ConsoleMessage, 0, 100),
consoleLogs: make([]ConsoleMessage, 0, 1000),
consoleLimit: 1000,
}
cleanupOnError := func() {
cancel()
if wv.client != nil {
_ = wv.client.Close()
}
}
for _, opt := range opts {
if err := opt(wv); err != nil {
cancel()
cleanupOnError()
return nil, err
}
}
@ -132,7 +139,7 @@ func New(opts ...Option) (*Webview, error) {
// Enable console capture
if err := wv.enableConsole(); err != nil {
cancel()
cleanupOnError()
return nil, coreerr.E("Webview.New", "failed to enable console capture", err)
}
@ -329,7 +336,7 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
if selector == "" {
script = "document.documentElement.outerHTML"
} else {
script = fmt.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
script = core.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
}
result, err := wv.evaluate(ctx, script)
@ -412,7 +419,8 @@ func (wv *Webview) addConsoleMessage(msg ConsoleMessage) {
if len(wv.consoleLogs) >= wv.consoleLimit {
// 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)
}
@ -454,14 +462,14 @@ func (wv *Webview) handleConsoleEvent(params map[string]any) {
// Extract args
args, _ := params["args"].([]any)
var text strings.Builder
text := core.NewBuilder()
for i, arg := range args {
if argMap, ok := arg.(map[string]any); ok {
if val, ok := argMap["value"]; ok {
if i > 0 {
text.WriteString(" ")
}
text.WriteString(fmt.Sprint(val))
text.WriteString(core.Sprint(val))
}
}
}
@ -517,7 +525,7 @@ func (wv *Webview) waitForSelector(ctx context.Context, selector string) error {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
script := fmt.Sprintf("!!document.querySelector(%q)", selector)
script := core.Sprintf("!!document.querySelector(%q)", selector)
for {
select {
@ -541,6 +549,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{
"expression": script,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return nil, coreerr.E("Webview.evaluate", "failed to evaluate script", err)
@ -670,6 +679,8 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
}
}
innerHTML, innerText := wv.getElementContent(ctx, nodeID)
// Get bounding box
var box *BoundingBox
if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{
@ -695,10 +706,61 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
NodeID: nodeID,
TagName: tagName,
Attributes: attrs,
InnerHTML: innerHTML,
InnerText: innerText,
BoundingBox: box,
}, 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.
func (wv *Webview) click(ctx context.Context, selector string) error {
// Find element and get its center coordinates
@ -709,7 +771,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
if elem.BoundingBox == nil {
// 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)
return err
}
@ -738,7 +800,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
// typeText types text into an element.
func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
// 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)
if err != nil {
return coreerr.E("Webview.typeText", "failed to focus element", err)

View file

@ -1,6 +1,8 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"testing"
"time"
)
@ -333,3 +335,453 @@ func TestScrollIntoViewAction_Good(t *testing.T) {
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")
}
}