Compare commits

..

15 commits
v0.2.0 ... 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
15 changed files with 2154 additions and 341 deletions

View file

@ -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,10 +1,11 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"fmt"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
@ -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,11 +1,11 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"fmt"
"strings"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
@ -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 "dappco.re/go/core/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))
}
}
}
@ -302,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)
}
}
@ -361,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.
@ -372,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
@ -425,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.
@ -450,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():
@ -478,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 {
@ -487,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)))
}
}
}
@ -515,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 {
@ -538,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

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

4
go.mod
View file

@ -4,6 +4,6 @@ go 1.26.0
require github.com/gorilla/websocket v1.5.3
require dappco.re/go/core/log v0.0.4
require dappco.re/go/core/log v0.1.0
replace dappco.re/go/core/log v0.0.4 => forge.lthn.ai/core/go-log v0.0.4
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,13 +25,12 @@ package webview
import (
"context"
"encoding/base64"
"fmt"
"iter"
"slices"
"strings"
"sync"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
@ -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)
@ -455,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))
}
}
}
@ -518,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 {
@ -542,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)
@ -671,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{
@ -696,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
@ -710,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
}
@ -739,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,3 +1,4 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
@ -427,6 +428,8 @@ func TestFormatJSValue_Good(t *testing.T) {
{nil, "null"},
{42, "42"},
{3.14, "3.14"},
{map[string]any{"enabled": true}, `{"enabled":true}`},
{[]any{1, "two"}, `[1,"two"]`},
}
for _, tc := range tests {
@ -455,6 +458,26 @@ func TestGetString_Good(t *testing.T) {
}
}
// 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())
@ -512,7 +535,7 @@ func TestConsoleWatcherFilter_Good(t *testing.T) {
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]ConsoleHandler, 0),
handlers: make([]consoleHandlerRegistration, 0),
}
// No filters — everything matches
@ -556,7 +579,7 @@ func TestConsoleWatcherCounts_Good(t *testing.T) {
},
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]ConsoleHandler, 0),
handlers: make([]consoleHandlerRegistration, 0),
}
if cw.Count() != 5 {
@ -592,7 +615,7 @@ func TestConsoleWatcherCounts_Good(t *testing.T) {
func TestExceptionWatcher_Good(t *testing.T) {
ew := &ExceptionWatcher{
exceptions: make([]ExceptionInfo, 0),
handlers: make([]func(ExceptionInfo), 0),
handlers: make([]exceptionHandlerRegistration, 0),
}
if ew.HasExceptions() {
@ -682,7 +705,7 @@ func TestConsoleWatcherAddMessage_Good(t *testing.T) {
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 5,
handlers: make([]ConsoleHandler, 0),
handlers: make([]consoleHandlerRegistration, 0),
}
// Add messages past the limit
@ -704,7 +727,7 @@ func TestConsoleWatcherHandler_Good(t *testing.T) {
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]ConsoleHandler, 0),
handlers: make([]consoleHandlerRegistration, 0),
}
var received ConsoleMessage
@ -729,7 +752,7 @@ func TestConsoleWatcherFilteredMessages_Good(t *testing.T) {
},
filters: []ConsoleFilter{{Type: "error"}},
limit: 1000,
handlers: make([]ConsoleHandler, 0),
handlers: make([]consoleHandlerRegistration, 0),
}
filtered := cw.FilteredMessages()