Compare commits

..

47 commits
v0.1.5 ... dev

Author SHA1 Message Date
Snider
4a2d5579e1 Add file upload and drag-drop actions
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
2026-04-16 00:24:17 +01:00
Snider
de13a29d79 Add missing webview unit coverage
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-16 00:22:09 +01:00
Snider
8a116ca78d fix(console): bound exception retention
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-16 00:19:04 +01:00
Snider
fd15f95ca9 Fix console and exception waiter races
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-16 00:17:03 +01:00
Snider
f38ceb3bd6 Add missing webview tests
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-16 00:15:00 +01:00
Snider
40d2e584aa chore: confirm RFC parity
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-16 00:06:31 +01:00
Snider
82eec17dfe fix(cdp): harden debug transport and navigation
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Restrict DevTools access to loopback hosts, bound debug and WebSocket reads, validate navigation targets, and sanitise formatted console output.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-16 00:04:51 +01:00
Snider
0c6c53e189 feat(webview): confirm RFC parity
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-16 00:01:59 +01:00
Snider
63a3a39916 docs(ax): add usage-first API examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-16 00:01:06 +01:00
Snider
5836e1236c Implement webview RFC gaps
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 23:58:01 +01:00
Snider
a67fe9e1c3 fix(console): normalise warning aliases
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-15 23:55:36 +01:00
Snider
6759f42a4c Align webview console and tab lifecycle
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 23:54:06 +01:00
Snider
4069bbe263 Guard watcher constructors against nil clients
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 23:52:21 +01:00
Snider
8a72b3ebc6 chore: verify go-webview RFC parity
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 23:50:43 +01:00
Snider
7b12244813 Align webview RFC surface
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 23:49:11 +01:00
Snider
3df32be96f Verify go-webview RFC compliance
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 23:47:46 +01:00
Snider
29e5d1bd37 Align webview console and evaluate behavior
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-15 16:47:59 +01:00
Snider
7de347e5f7 Improve internal CDP names 2026-04-15 16:45:24 +01:00
Snider
d5d3525602 refactor(actions): expose fluent builders for all actions
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-15 14:30:45 +01:00
Snider
284d39de18 Align console warning types with RFC 2026-04-15 14:29:06 +01:00
Snider
75ed16743a Align console handling with AX principles 2026-04-15 14:27:13 +01:00
Snider
f5d14bb39d Align console capture with RFC semantics 2026-04-15 14:25:33 +01:00
Snider
d673ce14be fix(webview): use protocol-correct history navigation
Replace the invalid Page.goBackOrForward call with Page.getNavigationHistory and Page.navigateToHistoryEntry, and update the regression coverage plus local docs.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-15 14:23:44 +01:00
Snider
f6e235d83e Align webview behavior with RFC 2026-04-15 14:21:23 +01:00
Snider
d6fcd6a2f4 fix(console): defer limit trimming until append
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-15 14:19:00 +01:00
Snider
f4a094e87c Align console filters with spec 2026-04-15 14:17:21 +01:00
Snider
3002b4801d feat(webview): CDP history nav + deterministic console limits + exception text
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
- webview.go: back/forward navigation uses Page.getNavigationHistory +
  navigateToHistoryEntry (bounds-checked, load-wait) — replaces deprecated
  Page.goBackOrForward
- console.go + webview.go: console limits deterministic, clamp negative limits
  to 0, zero-retention supported, error counting normalised, multiple
  ConsoleWatcher filters compose as intersection
- webview.go + console.go + angular.go: JS exception text propagation, Angular
  router/query param map[string]string per RFC
- audit_issue2_test.go + webview_test.go: history nav, exception text, filter
  composition, retention trimming coverage

Verified: GOWORK=off go test ./... passes

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 18:35:10 +01:00
Snider
7a4450cf9f feat(webview): console watcher additions + canonical type normalisation
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Spark draft + opus-style fixes during integration:

- normalizeConsoleType applied consistently across FormatConsoleOutput
  + Errors/ErrorsAll + Warnings/WarningsAll iterators — previously raw
  "warning" inputs missed the "warn" comparisons silently
- core.Trim instead of non-existent core.TrimSpace

Spark feature work preserved (Errors/Warnings iterators, formatted
console output, type normalisation helper).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 15:09:57 +01:00
Snider
a6304b8e29 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Some checks failed
Test / test (push) Has been cancelled
Security Scan / security (push) Has been cancelled
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

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

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

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

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

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

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

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

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 08:54:22 +00:00
23 changed files with 6007 additions and 519 deletions

View file

@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Module: `forge.lthn.ai/core/go-webview` — Chrome DevTools Protocol client for browser automation. Module: `dappco.re/go/core/webview` — Chrome DevTools Protocol client for browser automation.
## Commands ## Commands
@ -56,7 +56,7 @@ Key patterns:
- Co-author trailer on every commit: `Co-Authored-By: Virgil <virgil@lethean.io>` - Co-author trailer on every commit: `Co-Authored-By: Virgil <virgil@lethean.io>`
- Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases) - Test naming: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge cases)
- Standard `testing.T` only — no test frameworks - Standard `testing.T` only — no test frameworks
- Wrap errors with `fmt.Errorf("context: %w", err)` - Wrap errors with `coreerr.E("Scope.Method", "description", err)` from `go-log`, never `fmt.Errorf`
- Protect shared state with `sync.RWMutex`; copy handler slices before calling outside lock - Protect shared state with `sync.RWMutex`; copy handler slices before calling outside lock
## Docs ## Docs

View file

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

View file

@ -1,11 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"fmt"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// Action represents a browser action that can be performed. // Action represents a browser action that can be performed.
@ -41,13 +42,7 @@ type NavigateAction struct {
// Execute performs the navigate action. // Execute performs the navigate action.
func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error { func (a NavigateAction) Execute(ctx context.Context, wv *Webview) error {
_, err := wv.client.Call(ctx, "Page.navigate", map[string]any{ return wv.navigate(ctx, a.URL, "NavigateAction.Execute")
"url": a.URL,
})
if err != nil {
return coreerr.E("NavigateAction.Execute", "failed to navigate", err)
}
return wv.waitForLoad(ctx)
} }
// WaitAction represents a wait action. // WaitAction represents a wait action.
@ -57,10 +52,13 @@ type WaitAction struct {
// Execute performs the wait action. // Execute performs the wait action.
func (a WaitAction) Execute(ctx context.Context, wv *Webview) error { func (a WaitAction) Execute(ctx context.Context, wv *Webview) error {
timer := time.NewTimer(a.Duration)
defer timer.Stop()
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
case <-time.After(a.Duration): case <-timer.C:
return nil return nil
} }
} }
@ -83,7 +81,7 @@ type ScrollAction struct {
// Execute performs the scroll action. // Execute performs the scroll action.
func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error { func (a ScrollAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y) script := core.Sprintf("window.scrollTo(%d, %d)", a.X, a.Y)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -95,7 +93,7 @@ type ScrollIntoViewAction struct {
// Execute scrolls the element into view. // Execute scrolls the element into view.
func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error { func (a ScrollIntoViewAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector) script := core.Sprintf("document.querySelector(%q)?.scrollIntoView({behavior: 'smooth', block: 'center'})", a.Selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -107,7 +105,7 @@ type FocusAction struct {
// Execute focuses the element. // Execute focuses the element.
func (a FocusAction) Execute(ctx context.Context, wv *Webview) error { func (a FocusAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.focus()", a.Selector) script := core.Sprintf("document.querySelector(%q)?.focus()", a.Selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -119,7 +117,7 @@ type BlurAction struct {
// Execute removes focus from the element. // Execute removes focus from the element.
func (a BlurAction) Execute(ctx context.Context, wv *Webview) error { func (a BlurAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.blur()", a.Selector) script := core.Sprintf("document.querySelector(%q)?.blur()", a.Selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -131,7 +129,7 @@ type ClearAction struct {
// Execute clears the input value. // Execute clears the input value.
func (a ClearAction) Execute(ctx context.Context, wv *Webview) error { func (a ClearAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
el.value = ''; el.value = '';
@ -151,7 +149,7 @@ type SelectAction struct {
// Execute selects the option. // Execute selects the option.
func (a SelectAction) Execute(ctx context.Context, wv *Webview) error { func (a SelectAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
el.value = %q; el.value = %q;
@ -170,7 +168,7 @@ type CheckAction struct {
// Execute checks/unchecks the checkbox. // Execute checks/unchecks the checkbox.
func (a CheckAction) Execute(ctx context.Context, wv *Webview) error { func (a CheckAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el && el.checked !== %t) { if (el && el.checked !== %t) {
el.click(); el.click();
@ -221,7 +219,7 @@ func (a DoubleClickAction) Execute(ctx context.Context, wv *Webview) error {
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
// Fallback to JavaScript // Fallback to JavaScript
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window}); const event = new MouseEvent('dblclick', {bubbles: true, cancelable: true, view: window});
@ -268,7 +266,7 @@ func (a RightClickAction) Execute(ctx context.Context, wv *Webview) error {
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
// Fallback to JavaScript // Fallback to JavaScript
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window}); const event = new MouseEvent('contextmenu', {bubbles: true, cancelable: true, view: window});
@ -376,7 +374,7 @@ type SetAttributeAction struct {
// Execute sets the attribute. // Execute sets the attribute.
func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error { func (a SetAttributeAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.setAttribute(%q, %q)", a.Selector, a.Attribute, a.Value) script := core.Sprintf("document.querySelector(%q)?.setAttribute(%q, %q)", a.Selector, a.Attribute, a.Value)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -389,7 +387,7 @@ type RemoveAttributeAction struct {
// Execute removes the attribute. // Execute removes the attribute.
func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error { func (a RemoveAttributeAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute) script := core.Sprintf("document.querySelector(%q)?.removeAttribute(%q)", a.Selector, a.Attribute)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -402,7 +400,7 @@ type SetValueAction struct {
// Execute sets the value. // Execute sets the value.
func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error { func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
script := fmt.Sprintf(` script := core.Sprintf(`
const el = document.querySelector(%q); const el = document.querySelector(%q);
if (el) { if (el) {
el.value = %q; el.value = %q;
@ -414,12 +412,45 @@ func (a SetValueAction) Execute(ctx context.Context, wv *Webview) error {
return err return err
} }
// UploadFileAction uploads files into a file input resolved by selector.
type UploadFileAction struct {
Selector string
FilePaths []string
}
// Execute uploads files into the matching file input.
func (a UploadFileAction) Execute(ctx context.Context, wv *Webview) error {
if wv == nil {
return coreerr.E("UploadFileAction.Execute", "webview is required", nil)
}
return wv.uploadFile(ctx, a.Selector, a.FilePaths)
}
// DragAndDropAction drags one element onto another.
type DragAndDropAction struct {
SourceSelector string
TargetSelector string
}
// Execute drags the source element onto the target element.
func (a DragAndDropAction) Execute(ctx context.Context, wv *Webview) error {
if wv == nil {
return coreerr.E("DragAndDropAction.Execute", "webview is required", nil)
}
return wv.dragAndDrop(ctx, a.SourceSelector, a.TargetSelector)
}
// ActionSequence represents a sequence of actions to execute. // ActionSequence represents a sequence of actions to execute.
type ActionSequence struct { type ActionSequence struct {
actions []Action actions []Action
} }
// NewActionSequence creates a new action sequence. // Build a reusable action pipeline before executing it against a Webview.
//
// sequence := webview.NewActionSequence().
// Navigate("https://example.com").
// WaitForSelector("form").
// Click("button")
func NewActionSequence() *ActionSequence { func NewActionSequence() *ActionSequence {
return &ActionSequence{ return &ActionSequence{
actions: make([]Action, 0), actions: make([]Action, 0),
@ -457,11 +488,97 @@ func (s *ActionSequence) WaitForSelector(selector string) *ActionSequence {
return s.Add(WaitForSelectorAction{Selector: selector}) return s.Add(WaitForSelectorAction{Selector: selector})
} }
// Scroll adds a scroll action.
func (s *ActionSequence) Scroll(x, y int) *ActionSequence {
return s.Add(ScrollAction{X: x, Y: y})
}
// ScrollIntoView adds a scroll-into-view action.
func (s *ActionSequence) ScrollIntoView(selector string) *ActionSequence {
return s.Add(ScrollIntoViewAction{Selector: selector})
}
// Focus adds a focus action.
func (s *ActionSequence) Focus(selector string) *ActionSequence {
return s.Add(FocusAction{Selector: selector})
}
// Blur adds a blur action.
func (s *ActionSequence) Blur(selector string) *ActionSequence {
return s.Add(BlurAction{Selector: selector})
}
// Clear adds a clear action.
func (s *ActionSequence) Clear(selector string) *ActionSequence {
return s.Add(ClearAction{Selector: selector})
}
// Select adds a select action.
func (s *ActionSequence) Select(selector, value string) *ActionSequence {
return s.Add(SelectAction{Selector: selector, Value: value})
}
// Check adds a check action.
func (s *ActionSequence) Check(selector string, checked bool) *ActionSequence {
return s.Add(CheckAction{Selector: selector, Checked: checked})
}
// Hover adds a hover action.
func (s *ActionSequence) Hover(selector string) *ActionSequence {
return s.Add(HoverAction{Selector: selector})
}
// DoubleClick adds a double-click action.
func (s *ActionSequence) DoubleClick(selector string) *ActionSequence {
return s.Add(DoubleClickAction{Selector: selector})
}
// RightClick adds a right-click action.
func (s *ActionSequence) RightClick(selector string) *ActionSequence {
return s.Add(RightClickAction{Selector: selector})
}
// PressKey adds a key press action.
func (s *ActionSequence) PressKey(key string) *ActionSequence {
return s.Add(PressKeyAction{Key: key})
}
// SetAttribute adds a set-attribute action.
func (s *ActionSequence) SetAttribute(selector, attribute, value string) *ActionSequence {
return s.Add(SetAttributeAction{Selector: selector, Attribute: attribute, Value: value})
}
// RemoveAttribute adds a remove-attribute action.
func (s *ActionSequence) RemoveAttribute(selector, attribute string) *ActionSequence {
return s.Add(RemoveAttributeAction{Selector: selector, Attribute: attribute})
}
// SetValue adds a set-value action.
func (s *ActionSequence) SetValue(selector, value string) *ActionSequence {
return s.Add(SetValueAction{Selector: selector, Value: value})
}
// UploadFile adds a file-upload action.
func (s *ActionSequence) UploadFile(selector string, filePaths []string) *ActionSequence {
return s.Add(UploadFileAction{
Selector: selector,
FilePaths: append([]string(nil), filePaths...),
})
}
// DragAndDrop adds a drag-and-drop action.
func (s *ActionSequence) DragAndDrop(sourceSelector, targetSelector string) *ActionSequence {
return s.Add(DragAndDropAction{
SourceSelector: sourceSelector,
TargetSelector: targetSelector,
})
}
// Execute executes all actions in the sequence. // Execute executes all actions in the sequence.
func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error { func (s *ActionSequence) Execute(ctx context.Context, wv *Webview) error {
for i, action := range s.actions { for i, action := range s.actions {
if err := action.Execute(ctx, wv); err != nil { if err := action.Execute(ctx, wv); err != nil {
return coreerr.E("ActionSequence.Execute", fmt.Sprintf("action %d failed", i), err) return coreerr.E("ActionSequence.Execute", core.Sprintf("action index %d failed", i), err)
} }
} }
return nil return nil
@ -472,10 +589,14 @@ func (wv *Webview) UploadFile(selector string, filePaths []string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
return wv.uploadFile(ctx, selector, filePaths)
}
func (wv *Webview) uploadFile(ctx context.Context, selector string, filePaths []string) error {
// Get the element's node ID // Get the element's node ID
elem, err := wv.querySelector(ctx, selector) elem, err := wv.querySelector(ctx, selector)
if err != nil { if err != nil {
return err return coreerr.E("Webview.UploadFile", "failed to find file input", err)
} }
// Use DOM.setFileInputFiles to set the files // Use DOM.setFileInputFiles to set the files
@ -483,7 +604,10 @@ func (wv *Webview) UploadFile(selector string, filePaths []string) error {
"nodeId": elem.NodeID, "nodeId": elem.NodeID,
"files": filePaths, "files": filePaths,
}) })
return err if err != nil {
return coreerr.E("Webview.UploadFile", "failed to upload file", err)
}
return nil
} }
// DragAndDrop performs a drag and drop operation. // DragAndDrop performs a drag and drop operation.
@ -491,6 +615,10 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
return wv.dragAndDrop(ctx, sourceSelector, targetSelector)
}
func (wv *Webview) dragAndDrop(ctx context.Context, sourceSelector, targetSelector string) error {
// Get source and target elements // Get source and target elements
source, err := wv.querySelector(ctx, sourceSelector) source, err := wv.querySelector(ctx, sourceSelector)
if err != nil { if err != nil {
@ -523,7 +651,7 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
"clickCount": 1, "clickCount": 1,
}) })
if err != nil { if err != nil {
return err return coreerr.E("Webview.DragAndDrop", "failed to press source element", err)
} }
// Move to target // Move to target
@ -534,7 +662,7 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
"button": "left", "button": "left",
}) })
if err != nil { if err != nil {
return err return coreerr.E("Webview.DragAndDrop", "failed to move to target element", err)
} }
// Mouse up on target // Mouse up on target
@ -545,5 +673,8 @@ func (wv *Webview) DragAndDrop(sourceSelector, targetSelector string) error {
"button": "left", "button": "left",
"clickCount": 1, "clickCount": 1,
}) })
return err if err != nil {
return coreerr.E("Webview.DragAndDrop", "failed to release target element", err)
}
return nil
} }

571
actions_test.go Normal file
View file

@ -0,0 +1,571 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func newActionHarness(t *testing.T, onMessage func(*fakeCDPTarget, cdpMessage)) (*Webview, *fakeCDPTarget) {
t.Helper()
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = onMessage
client := newConnectedCDPClient(t, target)
wv := &Webview{
client: client,
ctx: context.Background(),
timeout: time.Second,
consoleLogs: make([]ConsoleMessage, 0),
consoleLimit: 10,
}
t.Cleanup(func() {
_ = client.Close()
})
return wv, target
}
func TestActions_ActionSequence_Good(t *testing.T) {
var methods []string
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
switch msg.Method {
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
if expr == "document.readyState" {
target.replyValue(msg.ID, "complete")
return
}
target.replyValue(msg.ID, true)
case "Page.navigate":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
seq := NewActionSequence().
Scroll(10, 20).
Focus("#input").
Navigate("https://example.com")
if err := seq.Execute(context.Background(), wv); err != nil {
t.Fatalf("ActionSequence.Execute returned error: %v", err)
}
if len(methods) != 4 {
t.Fatalf("ActionSequence.Execute methods = %v, want 4 calls", methods)
}
if methods[0] != "Runtime.evaluate" || methods[1] != "Runtime.evaluate" || methods[2] != "Page.navigate" || methods[3] != "Runtime.evaluate" {
t.Fatalf("ActionSequence.Execute call order = %v", methods)
}
}
func TestActions_EvaluateActions_Good(t *testing.T) {
tests := []struct {
name string
action Action
wantSub string
}{
{name: "scroll", action: ScrollAction{X: 10, Y: 20}, wantSub: "window.scrollTo(10, 20)"},
{name: "scroll into view", action: ScrollIntoViewAction{Selector: "#target"}, wantSub: `scrollIntoView({behavior: 'smooth', block: 'center'})`},
{name: "focus", action: FocusAction{Selector: "#input"}, wantSub: `?.focus()`},
{name: "blur", action: BlurAction{Selector: "#input"}, wantSub: `?.blur()`},
{name: "clear", action: ClearAction{Selector: "#input"}, wantSub: `el.value = '';`},
{name: "select", action: SelectAction{Selector: "#dropdown", Value: "option1"}, wantSub: `el.value = "option1";`},
{name: "check", action: CheckAction{Selector: "#checkbox", Checked: true}, wantSub: `el && el.checked !== true`},
{name: "set attribute", action: SetAttributeAction{Selector: "#element", Attribute: "data-value", Value: "test"}, wantSub: `setAttribute("data-value", "test")`},
{name: "remove attribute", action: RemoveAttributeAction{Selector: "#element", Attribute: "disabled"}, wantSub: `removeAttribute("disabled")`},
{name: "set value", action: SetValueAction{Selector: "#input", Value: "new value"}, wantSub: `el.value = "new value";`},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
expr, _ := msg.Params["expression"].(string)
if !strings.Contains(expr, tc.wantSub) {
t.Fatalf("expression %q does not contain %q", expr, tc.wantSub)
}
target.replyValue(msg.ID, true)
})
if err := tc.action.Execute(context.Background(), wv); err != nil {
t.Fatalf("%T.Execute returned error: %v", tc.action, err)
}
})
}
}
func TestActions_TypeAction_Good(t *testing.T) {
var methods []string
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
switch msg.Method {
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
if !strings.Contains(expr, `document.querySelector("#email")?.focus()`) {
t.Fatalf("focus expression = %q", expr)
}
target.replyValue(msg.ID, true)
case "Input.dispatchKeyEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := (TypeAction{Selector: "#email", Text: "ab"}).Execute(context.Background(), wv); err != nil {
t.Fatalf("TypeAction.Execute returned error: %v", err)
}
if len(methods) != 5 {
t.Fatalf("TypeAction made %d CDP calls, want 5", len(methods))
}
if methods[0] != "Runtime.evaluate" || methods[1] != "Input.dispatchKeyEvent" || methods[2] != "Input.dispatchKeyEvent" || methods[3] != "Input.dispatchKeyEvent" || methods[4] != "Input.dispatchKeyEvent" {
t.Fatalf("TypeAction call order = %v", methods)
}
}
func TestActions_DomActions_Good(t *testing.T) {
tests := []struct {
name string
action Action
handler func(*testing.T, *fakeCDPTarget, cdpMessage)
check func(*testing.T, []cdpMessage)
}{
{
name: "click",
action: ClickAction{Selector: "#button"},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON", "attributes": []any{"id", "button"}}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "<span>ok</span>", "innerText": "ok"}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(10), float64(20), float64(30), float64(20), float64(30), float64(40), float64(10), float64(40)}}})
case "Input.dispatchMouseEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 8 {
t.Fatalf("click made %d CDP calls, want 8", len(msgs))
}
},
},
{
name: "hover",
action: HoverAction{Selector: "#menu"},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(11)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-2"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(20), float64(0), float64(20), float64(20), float64(0), float64(20)}}})
case "Input.dispatchMouseEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 7 {
t.Fatalf("hover made %d CDP calls, want 7", len(msgs))
}
},
},
{
name: "double click",
action: DoubleClickAction{Selector: "#editable"},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(12)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-3"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(10), float64(0), float64(10), float64(10), float64(0), float64(10)}}})
case "Input.dispatchMouseEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 10 {
t.Fatalf("double click made %d CDP calls, want 10", len(msgs))
}
},
},
{
name: "right click",
action: RightClickAction{Selector: "#context"},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(13)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-4"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(10), float64(0), float64(10), float64(10), float64(0), float64(10)}}})
case "Input.dispatchMouseEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 8 {
t.Fatalf("right click made %d CDP calls, want 8", len(msgs))
}
},
},
{
name: "press key",
action: PressKeyAction{Key: "Enter"},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Input.dispatchKeyEvent" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.reply(msg.ID, map[string]any{})
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 2 {
t.Fatalf("press key made %d CDP calls, want 2", len(msgs))
}
},
},
{
name: "upload file",
action: &uploadFileAction{selector: "#file", files: []string{"/tmp/a.txt"}},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(22)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "INPUT"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-5"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(0), float64(0), float64(1), float64(0), float64(1), float64(1), float64(0), float64(1)}}})
case "DOM.setFileInputFiles":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 7 {
t.Fatalf("upload file made %d CDP calls, want 7", len(msgs))
}
},
},
{
name: "drag and drop",
action: &dragDropAction{source: "#source", target: "#target"},
handler: func(t *testing.T, target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
sel, _ := msg.Params["selector"].(string)
switch sel {
case "#source":
target.reply(msg.ID, map[string]any{"nodeId": float64(31)})
case "#target":
target.reply(msg.ID, map[string]any{"nodeId": float64(32)})
default:
t.Fatalf("unexpected selector %q", sel)
}
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-6"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
nodeID := int(msg.Params["nodeId"].(float64))
box := []any{float64(nodeID), float64(nodeID), float64(nodeID + 10), float64(nodeID), float64(nodeID + 10), float64(nodeID + 10), float64(nodeID), float64(nodeID + 10)}
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": box}})
case "Input.dispatchMouseEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
},
check: func(t *testing.T, msgs []cdpMessage) {
if len(msgs) != 15 {
t.Fatalf("drag and drop made %d CDP calls, want 15", len(msgs))
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var msgs []cdpMessage
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
msgs = append(msgs, msg)
tc.handler(t, target, msg)
})
err := tc.action.Execute(context.Background(), wv)
if err != nil {
t.Fatalf("%T.Execute returned error: %v", tc.action, err)
}
tc.check(t, msgs)
})
}
}
func TestActions_DoubleClickAction_Ugly_FallsBackToJS(t *testing.T) {
var expressions []string
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
expressions = append(expressions, expr)
target.replyValue(msg.ID, true)
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := (DoubleClickAction{Selector: "#button"}).Execute(context.Background(), wv); err != nil {
t.Fatalf("DoubleClickAction.Execute returned error: %v", err)
}
if len(expressions) != 1 || !strings.Contains(expressions[0], `new MouseEvent('dblclick'`) {
t.Fatalf("DoubleClickAction fallback expression = %v", expressions)
}
}
func TestActions_RightClickAction_Ugly_FallsBackToJS(t *testing.T) {
var expressions []string
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(11)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-2"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
expressions = append(expressions, expr)
target.replyValue(msg.ID, true)
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := (RightClickAction{Selector: "#button"}).Execute(context.Background(), wv); err != nil {
t.Fatalf("RightClickAction.Execute returned error: %v", err)
}
if len(expressions) != 1 || !strings.Contains(expressions[0], `new MouseEvent('contextmenu'`) {
t.Fatalf("RightClickAction fallback expression = %v", expressions)
}
}
func TestActions_PressKeyAction_Good_SimpleCharacter(t *testing.T) {
var methods []string
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
if msg.Method != "Input.dispatchKeyEvent" {
t.Fatalf("unexpected method %q", msg.Method)
}
if msg.Params["type"] == "keyDown" {
if got := msg.Params["text"]; got != "a" {
t.Fatalf("keyDown text = %v, want a", got)
}
}
target.reply(msg.ID, map[string]any{})
})
if err := (PressKeyAction{Key: "a"}).Execute(context.Background(), wv); err != nil {
t.Fatalf("PressKeyAction.Execute returned error: %v", err)
}
if len(methods) != 2 {
t.Fatalf("PressKeyAction made %d CDP calls, want 2", len(methods))
}
}
func TestActions_ActionSequence_Bad_StopsOnError(t *testing.T) {
seq := NewActionSequence().
Add(failingAction{}).
Add(recordingAction{})
err := seq.Execute(context.Background(), &Webview{})
if err == nil {
t.Fatal("ActionSequence.Execute succeeded despite a failing action")
}
if !strings.Contains(err.Error(), "action index 0 failed") {
t.Fatalf("ActionSequence.Execute error = %v, want wrapped index failure", err)
}
}
func TestActions_WaitForSelectorAction_Good(t *testing.T) {
var calls int
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
calls++
if calls == 1 {
target.replyValue(msg.ID, false)
return
}
target.replyValue(msg.ID, true)
})
if err := (WaitForSelectorAction{Selector: "#ready"}).Execute(context.Background(), wv); err != nil {
t.Fatalf("WaitForSelectorAction.Execute returned error: %v", err)
}
if calls < 2 {
t.Fatalf("WaitForSelectorAction made %d evaluate calls, want at least 2", calls)
}
}
func TestActions_HoverAction_Bad_MissingBoundingBox(t *testing.T) {
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := (HoverAction{Selector: "#menu"}).Execute(context.Background(), wv); err == nil {
t.Fatal("HoverAction succeeded without a bounding box")
}
}
func TestActions_ClickAction_Ugly_FallsBackToJS(t *testing.T) {
var expressions []string
wv, _ := newActionHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
expressions = append(expressions, expr)
target.replyValue(msg.ID, true)
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := (ClickAction{Selector: "#button"}).Execute(context.Background(), wv); err != nil {
t.Fatalf("ClickAction returned error: %v", err)
}
if len(expressions) != 1 || !strings.Contains(expressions[0], `document.querySelector("#button")?.click()`) {
t.Fatalf("ClickAction fallback expression = %v", expressions)
}
}
type failingAction struct{}
func (failingAction) Execute(context.Context, *Webview) error {
return errors.New("boom")
}
type recordingAction struct{}
func (recordingAction) Execute(context.Context, *Webview) error {
return nil
}
type uploadFileAction struct {
selector string
files []string
}
func (a *uploadFileAction) Execute(ctx context.Context, wv *Webview) error {
return wv.UploadFile(a.selector, a.files)
}
type dragDropAction struct {
source string
target string
}
func (a *dragDropAction) Execute(ctx context.Context, wv *Webview) error {
return wv.DragAndDrop(a.source, a.target)
}

View file

@ -1,12 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"fmt"
"strings"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// AngularHelper provides Angular-specific testing utilities. // AngularHelper provides Angular-specific testing utilities.
@ -15,7 +15,10 @@ type AngularHelper struct {
timeout time.Duration timeout time.Duration
} }
// NewAngularHelper creates a new Angular helper for the webview. // Create Angular-specific helpers for a page already loaded in the Webview.
//
// ah := webview.NewAngularHelper(wv)
// ah.SetTimeout(15 * time.Second)
func NewAngularHelper(wv *Webview) *AngularHelper { func NewAngularHelper(wv *Webview) *AngularHelper {
return &AngularHelper{ return &AngularHelper{
wv: wv, wv: wv,
@ -42,10 +45,10 @@ func (ah *AngularHelper) waitForAngular(ctx context.Context) error {
// Check if Angular is present // Check if Angular is present
isAngular, err := ah.isAngularApp(ctx) isAngular, err := ah.isAngularApp(ctx)
if err != nil { if err != nil {
return err return coreerr.E("AngularHelper.WaitForAngular", "failed to detect Angular app", err)
} }
if !isAngular { if !isAngular {
return coreerr.E("AngularHelper.waitForAngular", "not an Angular application", nil) return coreerr.E("AngularHelper.WaitForAngular", "not an Angular application", nil)
} }
// Wait for Zone.js stability // Wait for Zone.js stability
@ -78,7 +81,7 @@ func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) {
result, err := ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil { if err != nil {
return false, err return false, coreerr.E("AngularHelper.WaitForAngular", "failed to detect Angular app", err)
} }
isAngular, ok := result.(bool) isAngular, ok := result.(bool)
@ -93,6 +96,21 @@ func (ah *AngularHelper) isAngularApp(ctx context.Context) (bool, error) {
func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error { func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
script := ` script := `
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
const pollZone = () => {
if (!window.Zone || !window.Zone.current) {
resolve(true);
return;
}
const inner = window.Zone.current._inner || window.Zone.current;
if (!inner._hasPendingMicrotasks && !inner._hasPendingMacrotasks) {
resolve(true);
return;
}
setTimeout(pollZone, 50);
};
// Get the root elements // Get the root elements
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
if (roots.length === 0) { if (roots.length === 0) {
@ -121,28 +139,7 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
} }
if (!zone) { if (!zone) {
// Fallback: check window.Zone pollZone();
if (window.Zone && window.Zone.current && window.Zone.current._inner) {
const isStable = !window.Zone.current._inner._hasPendingMicrotasks &&
!window.Zone.current._inner._hasPendingMacrotasks;
if (isStable) {
resolve(true);
} else {
// Poll for stability
let attempts = 0;
const poll = setInterval(() => {
attempts++;
const stable = !window.Zone.current._inner._hasPendingMicrotasks &&
!window.Zone.current._inner._hasPendingMacrotasks;
if (stable || attempts > 100) {
clearInterval(poll);
resolve(stable);
}
}, 50);
}
} else {
resolve(true);
}
return; return;
} }
@ -153,29 +150,33 @@ func (ah *AngularHelper) waitForZoneStability(ctx context.Context) error {
} }
// Wait for stability // Wait for stability
try {
const sub = zone.onStable.subscribe(() => { const sub = zone.onStable.subscribe(() => {
sub.unsubscribe(); sub.unsubscribe();
resolve(true); resolve(true);
}); });
} catch (e) {
// Timeout fallback pollZone();
setTimeout(() => { }
sub.unsubscribe();
resolve(zone.isStable);
}, 5000);
}) })
` `
ticker := time.NewTicker(100 * time.Millisecond) result, err := ah.wv.evaluate(ctx, script)
defer ticker.Stop()
// First evaluate the promise
_, err := ah.wv.evaluate(ctx, script)
if err != nil { if err != nil {
// If the script fails, fall back to simple polling // If the script fails, fall back to simple polling
return ah.pollForStability(ctx) if pollErr := ah.pollForStability(ctx); pollErr != nil {
return coreerr.E("AngularHelper.WaitForAngular", "failed to wait for Zone stability", pollErr)
}
return nil
} }
if stable, ok := result.(bool); ok && stable {
return nil
}
if err := ah.pollForStability(ctx); err != nil {
return coreerr.E("AngularHelper.WaitForAngular", "failed to wait for Zone stability", err)
}
return nil return nil
} }
@ -197,7 +198,7 @@ func (ah *AngularHelper) pollForStability(ctx context.Context) error {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return coreerr.E("AngularHelper.WaitForComponent", "timed out waiting for component", ctx.Err())
case <-ticker.C: case <-ticker.C:
result, err := ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil { if err != nil {
@ -215,7 +216,7 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
if (roots.length === 0) { if (roots.length === 0) {
@ -244,7 +245,10 @@ func (ah *AngularHelper) NavigateByRouter(path string) error {
} }
// Wait for navigation to complete // Wait for navigation to complete
return ah.waitForZoneStability(ctx) if err := ah.waitForZoneStability(ctx); err != nil {
return coreerr.E("AngularHelper.NavigateByRouter", "failed to wait for router navigation", err)
}
return nil
} }
// GetRouterState returns the current Angular router state. // GetRouterState returns the current Angular router state.
@ -277,7 +281,7 @@ func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) {
result, err := ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil { if err != nil {
return nil, err return nil, coreerr.E("AngularHelper.GetRouterState", "failed to read router state", err)
} }
if result == nil { if result == nil {
@ -294,27 +298,12 @@ func (ah *AngularHelper) GetRouterState() (*AngularRouterState, error) {
URL: getString(resultMap, "url"), URL: getString(resultMap, "url"),
} }
if fragment, ok := resultMap["fragment"].(string); ok { if fragment, ok := resultMap["fragment"]; ok && fragment != nil {
state.Fragment = fragment state.Fragment = core.Sprint(fragment)
} }
if params, ok := resultMap["params"].(map[string]any); ok { state.Params = copyStringOnlyMap(resultMap["params"])
state.Params = make(map[string]string) state.QueryParams = copyStringOnlyMap(resultMap["queryParams"])
for k, v := range params {
if s, ok := v.(string); ok {
state.Params[k] = s
}
}
}
if queryParams, ok := resultMap["queryParams"].(map[string]any); ok {
state.QueryParams = make(map[string]string)
for k, v := range queryParams {
if s, ok := v.(string); ok {
state.QueryParams[k] = s
}
}
}
return state, nil return state, nil
} }
@ -332,21 +321,27 @@ func (ah *AngularHelper) GetComponentProperty(selector, propertyName string) (an
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
const propertyName = %s;
const element = document.querySelector(selector);
if (!element) { if (!element) {
throw new Error('Element not found: %s'); throw new Error('Element not found: ' + selector);
} }
const component = window.ng.probe(element).componentInstance; const component = window.ng.probe(element).componentInstance;
if (!component) { if (!component) {
throw new Error('No Angular component found on element'); throw new Error('No Angular component found on element');
} }
return component[%q]; return component[propertyName];
})() })()
`, selector, selector, propertyName) `, formatJSValue(selector), formatJSValue(propertyName))
return ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil {
return nil, coreerr.E("AngularHelper.GetComponentProperty", "failed to read component property", err)
}
return result, nil
} }
// SetComponentProperty sets a property on an Angular component. // SetComponentProperty sets a property on an Angular component.
@ -354,17 +349,19 @@ func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, val
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
const propertyName = %s;
const element = document.querySelector(selector);
if (!element) { if (!element) {
throw new Error('Element not found: %s'); throw new Error('Element not found: ' + selector);
} }
const component = window.ng.probe(element).componentInstance; const component = window.ng.probe(element).componentInstance;
if (!component) { if (!component) {
throw new Error('No Angular component found on element'); throw new Error('No Angular component found on element');
} }
component[%q] = %v; component[propertyName] = %s;
// Trigger change detection // Trigger change detection
const injector = window.ng.probe(element).injector; const injector = window.ng.probe(element).injector;
@ -374,10 +371,13 @@ func (ah *AngularHelper) SetComponentProperty(selector, propertyName string, val
} }
return true; return true;
})() })()
`, selector, selector, propertyName, formatJSValue(value)) `, formatJSValue(selector), formatJSValue(propertyName), formatJSValue(value))
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err if err != nil {
return coreerr.E("AngularHelper.SetComponentProperty", "failed to set component property", err)
}
return nil
} }
// CallComponentMethod calls a method on an Angular component. // CallComponentMethod calls a method on an Angular component.
@ -385,7 +385,7 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
var argsStr strings.Builder argsStr := core.NewBuilder()
for i, arg := range args { for i, arg := range args {
if i > 0 { if i > 0 {
argsStr.WriteString(", ") argsStr.WriteString(", ")
@ -393,20 +393,22 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
argsStr.WriteString(formatJSValue(arg)) argsStr.WriteString(formatJSValue(arg))
} }
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
const methodName = %s;
const element = document.querySelector(selector);
if (!element) { if (!element) {
throw new Error('Element not found: %s'); throw new Error('Element not found: ' + selector);
} }
const component = window.ng.probe(element).componentInstance; const component = window.ng.probe(element).componentInstance;
if (!component) { if (!component) {
throw new Error('No Angular component found on element'); throw new Error('No Angular component found on element');
} }
if (typeof component[%q] !== 'function') { if (typeof component[methodName] !== 'function') {
throw new Error('Method not found: %s'); throw new Error('Method not found: ' + methodName);
} }
const result = component[%q](%s); const result = component[methodName](%s);
// Trigger change detection // Trigger change detection
const injector = window.ng.probe(element).injector; const injector = window.ng.probe(element).injector;
@ -416,9 +418,13 @@ func (ah *AngularHelper) CallComponentMethod(selector, methodName string, args .
} }
return result; return result;
})() })()
`, selector, selector, methodName, methodName, methodName, argsStr.String()) `, formatJSValue(selector), formatJSValue(methodName), argsStr.String())
return ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil {
return nil, coreerr.E("AngularHelper.CallComponentMethod", "failed to call component method", err)
}
return result, nil
} }
// TriggerChangeDetection manually triggers Angular change detection. // TriggerChangeDetection manually triggers Angular change detection.
@ -446,7 +452,10 @@ func (ah *AngularHelper) TriggerChangeDetection() error {
` `
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err if err != nil {
return coreerr.E("AngularHelper.TriggerChangeDetection", "failed to trigger change detection", err)
}
return nil
} }
// GetService gets an Angular service by token name. // GetService gets an Angular service by token name.
@ -454,7 +463,7 @@ func (ah *AngularHelper) GetService(serviceName string) (any, error) {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : []; const roots = window.getAllAngularRootElements ? window.getAllAngularRootElements() : [];
for (const root of roots) { for (const root of roots) {
@ -473,7 +482,11 @@ func (ah *AngularHelper) GetService(serviceName string) (any, error) {
})() })()
`, serviceName) `, serviceName)
return ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil {
return nil, coreerr.E("AngularHelper.GetService", "failed to get Angular service", err)
}
return result, nil
} }
// WaitForComponent waits for an Angular component to be present. // WaitForComponent waits for an Angular component to be present.
@ -481,7 +494,7 @@ func (ah *AngularHelper) WaitForComponent(selector string) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const element = document.querySelector(%q);
if (!element) return false; if (!element) return false;
@ -523,20 +536,25 @@ func (ah *AngularHelper) DispatchEvent(selector, eventName string, detail any) e
detailStr = formatJSValue(detail) detailStr = formatJSValue(detail)
} }
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
const eventName = %s;
const element = document.querySelector(selector);
if (!element) { if (!element) {
throw new Error('Element not found: %s'); throw new Error('Element not found: ' + selector);
} }
const event = new CustomEvent(%q, { bubbles: true, detail: %s }); const event = new CustomEvent(eventName, { bubbles: true, detail: %s });
element.dispatchEvent(event); element.dispatchEvent(event);
return true; return true;
})() })()
`, selector, selector, eventName, detailStr) `, formatJSValue(selector), formatJSValue(eventName), detailStr)
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err if err != nil {
return coreerr.E("AngularHelper.DispatchEvent", "failed to dispatch event", err)
}
return nil
} }
// GetNgModel gets the value of an ngModel-bound input. // GetNgModel gets the value of an ngModel-bound input.
@ -544,7 +562,7 @@ func (ah *AngularHelper) GetNgModel(selector string) (any, error) {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const element = document.querySelector(%q);
if (!element) return null; if (!element) return null;
@ -563,7 +581,11 @@ func (ah *AngularHelper) GetNgModel(selector string) (any, error) {
})() })()
`, selector) `, selector)
return ah.wv.evaluate(ctx, script) result, err := ah.wv.evaluate(ctx, script)
if err != nil {
return nil, coreerr.E("AngularHelper.GetNgModel", "failed to read ngModel value", err)
}
return result, nil
} }
// SetNgModel sets the value of an ngModel-bound input. // SetNgModel sets the value of an ngModel-bound input.
@ -571,14 +593,15 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout) ctx, cancel := context.WithTimeout(ah.wv.ctx, ah.timeout)
defer cancel() defer cancel()
script := fmt.Sprintf(` script := core.Sprintf(`
(function() { (function() {
const element = document.querySelector(%q); const selector = %s;
const element = document.querySelector(selector);
if (!element) { if (!element) {
throw new Error('Element not found: %s'); throw new Error('Element not found: ' + selector);
} }
element.value = %v; element.value = %s;
element.dispatchEvent(new Event('input', { bubbles: true })); element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true })); element.dispatchEvent(new Event('change', { bubbles: true }));
@ -597,10 +620,13 @@ func (ah *AngularHelper) SetNgModel(selector string, value any) error {
return true; return true;
})() })()
`, selector, selector, formatJSValue(value)) `, formatJSValue(selector), formatJSValue(value))
_, err := ah.wv.evaluate(ctx, script) _, err := ah.wv.evaluate(ctx, script)
return err if err != nil {
return coreerr.E("AngularHelper.SetNgModel", "failed to set ngModel value", err)
}
return nil
} }
// Helper functions // Helper functions
@ -612,18 +638,37 @@ func getString(m map[string]any, key string) string {
return "" return ""
} }
func formatJSValue(v any) string { func copyStringOnlyMap(value any) map[string]string {
switch val := v.(type) { switch typed := value.(type) {
case string: case map[string]any:
return fmt.Sprintf("%q", val) result := make(map[string]string, len(typed))
case bool: for key, item := range typed {
if val { if text, ok := item.(string); ok {
return "true" result[key] = text
} }
return "false" }
case nil: return result
return "null" case map[string]string:
result := make(map[string]string, len(typed))
for key, item := range typed {
result[key] = item
}
return result
default: default:
return fmt.Sprintf("%v", val) return nil
} }
} }
func formatJSValue(v any) string {
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"
}

328
angular_test.go Normal file
View file

@ -0,0 +1,328 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"strings"
"testing"
"time"
)
func newAngularTestHarness(t *testing.T, onMessage func(*fakeCDPTarget, cdpMessage)) (*AngularHelper, *fakeCDPTarget, *CDPClient) {
t.Helper()
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = onMessage
client := newConnectedCDPClient(t, target)
wv := &Webview{
client: client,
ctx: context.Background(),
timeout: time.Second,
consoleLogs: make([]ConsoleMessage, 0),
consoleLimit: 10,
}
return NewAngularHelper(wv), target, client
}
func TestAngular_SetTimeout_Good(t *testing.T) {
ah := NewAngularHelper(&Webview{})
ah.SetTimeout(5 * time.Second)
if ah.timeout != 5*time.Second {
t.Fatalf("SetTimeout = %v, want 5s", ah.timeout)
}
}
func TestAngular_WaitForAngular_Bad_NotAngular(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, false)
})
if err := ah.WaitForAngular(); err == nil {
t.Fatal("WaitForAngular succeeded for a non-Angular page")
}
}
func TestAngular_WaitForAngular_Good(t *testing.T) {
var evaluateCount int
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
evaluateCount++
expr, _ := msg.Params["expression"].(string)
if strings.Contains(expr, "getAllAngularRootElements") || strings.Contains(expr, "[ng-version]") {
target.replyValue(msg.ID, true)
return
}
target.replyValue(msg.ID, true)
})
if err := ah.WaitForAngular(); err != nil {
t.Fatalf("WaitForAngular returned error: %v", err)
}
if evaluateCount < 2 {
t.Fatalf("WaitForAngular made %d evaluate calls, want at least 2", evaluateCount)
}
}
func TestAngular_waitForZoneStability_Good_FallsBackToPolling(t *testing.T) {
var calls int
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
calls++
expr, _ := msg.Params["expression"].(string)
switch {
case strings.Contains(expr, "new Promise"):
target.replyError(msg.ID, "zone probe failed")
default:
target.replyValue(msg.ID, true)
}
})
if err := ah.waitForZoneStability(context.Background()); err != nil {
t.Fatalf("waitForZoneStability returned error: %v", err)
}
if calls < 2 {
t.Fatalf("waitForZoneStability made %d evaluate calls, want at least 2", calls)
}
}
func TestAngular_NavigateByRouter_Good(t *testing.T) {
var expressions []string
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
expr, _ := msg.Params["expression"].(string)
expressions = append(expressions, expr)
if strings.Contains(expr, "navigateByUrl") {
target.replyValue(msg.ID, true)
return
}
target.replyValue(msg.ID, true)
})
if err := ah.NavigateByRouter("/dashboard"); err != nil {
t.Fatalf("NavigateByRouter returned error: %v", err)
}
if len(expressions) < 2 {
t.Fatalf("NavigateByRouter made %d evaluate calls, want at least 2", len(expressions))
}
}
func TestAngular_NavigateByRouter_Bad(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyError(msg.ID, "could not find router")
})
if err := ah.NavigateByRouter("/dashboard"); err == nil {
t.Fatal("NavigateByRouter succeeded despite evaluation error")
}
}
func TestAngular_GetComponentProperty_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
expr, _ := msg.Params["expression"].(string)
if !strings.Contains(expr, `const selector = "app-user";`) {
t.Fatalf("expression did not quote selector: %s", expr)
}
if !strings.Contains(expr, `const propertyName = "displayName";`) {
t.Fatalf("expression did not quote property name: %s", expr)
}
target.replyValue(msg.ID, "Ada")
})
got, err := ah.GetComponentProperty("app-user", "displayName")
if err != nil {
t.Fatalf("GetComponentProperty returned error: %v", err)
}
if got != "Ada" {
t.Fatalf("GetComponentProperty = %v, want Ada", got)
}
}
func TestAngular_SetComponentProperty_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
expr, _ := msg.Params["expression"].(string)
if !strings.Contains(expr, `component[propertyName] = true;`) {
t.Fatalf("expression did not set the component property: %s", expr)
}
target.replyValue(msg.ID, true)
})
if err := ah.SetComponentProperty("app-user", "active", true); err != nil {
t.Fatalf("SetComponentProperty returned error: %v", err)
}
}
func TestAngular_CallComponentMethod_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
expr, _ := msg.Params["expression"].(string)
if !strings.Contains(expr, `component[methodName](1, "two")`) {
t.Fatalf("expression did not marshal method args: %s", expr)
}
target.replyValue(msg.ID, map[string]any{"ok": true})
})
got, err := ah.CallComponentMethod("app-user", "save", 1, "two")
if err != nil {
t.Fatalf("CallComponentMethod returned error: %v", err)
}
if gotMap, ok := got.(map[string]any); !ok || gotMap["ok"] != true {
t.Fatalf("CallComponentMethod = %#v, want ok=true", got)
}
}
func TestAngular_TriggerChangeDetection_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, true)
})
if err := ah.TriggerChangeDetection(); err != nil {
t.Fatalf("TriggerChangeDetection returned error: %v", err)
}
}
func TestAngular_GetService_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, map[string]any{"name": "session"})
})
got, err := ah.GetService("SessionService")
if err != nil {
t.Fatalf("GetService returned error: %v", err)
}
if gotMap, ok := got.(map[string]any); !ok || gotMap["name"] != "session" {
t.Fatalf("GetService = %#v, want session map", got)
}
}
func TestAngular_WaitForComponent_Good(t *testing.T) {
var calls int
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
calls++
if calls == 1 {
target.replyValue(msg.ID, false)
return
}
target.replyValue(msg.ID, true)
})
if err := ah.WaitForComponent("app-user"); err != nil {
t.Fatalf("WaitForComponent returned error: %v", err)
}
if calls < 2 {
t.Fatalf("WaitForComponent calls = %d, want at least 2", calls)
}
}
func TestAngular_DispatchEvent_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
expr, _ := msg.Params["expression"].(string)
if !strings.Contains(expr, `new CustomEvent(eventName, { bubbles: true, detail: {"count":1} })`) && !strings.Contains(expr, `new CustomEvent(eventName, { bubbles: true, detail: {\"count\":1} })`) {
t.Fatalf("expression did not dispatch custom event with detail: %s", expr)
}
target.replyValue(msg.ID, true)
})
if err := ah.DispatchEvent("app-user", "count-change", map[string]any{"count": 1}); err != nil {
t.Fatalf("DispatchEvent returned error: %v", err)
}
}
func TestAngular_GetNgModel_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, "hello")
})
got, err := ah.GetNgModel("input[name=email]")
if err != nil {
t.Fatalf("GetNgModel returned error: %v", err)
}
if got != "hello" {
t.Fatalf("GetNgModel = %v, want hello", got)
}
}
func TestAngular_SetNgModel_Good(t *testing.T) {
ah, _, _ := newAngularTestHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, true)
})
if err := ah.SetNgModel(`input[name="x"]`, `";window.hacked=true;//`); err != nil {
t.Fatalf("SetNgModel returned error: %v", err)
}
}
func TestAngular_copyStringOnlyMap_Good(t *testing.T) {
tests := []struct {
name string
in any
want map[string]string
}{
{name: "map any", in: map[string]any{"a": "1", "b": 2}, want: map[string]string{"a": "1"}},
{name: "map string", in: map[string]string{"c": "3"}, want: map[string]string{"c": "3"}},
{name: "nil", in: nil, want: nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := copyStringOnlyMap(tc.in)
if len(got) != len(tc.want) {
t.Fatalf("copyStringOnlyMap len = %d, want %d", len(got), len(tc.want))
}
for k, want := range tc.want {
if got[k] != want {
t.Fatalf("copyStringOnlyMap[%q] = %q, want %q", k, got[k], want)
}
}
})
}
}
func TestAngular_formatJSValue_Ugly_FallsBackToSprint(t *testing.T) {
got := formatJSValue(make(chan int))
if got == "null" {
t.Fatalf("formatJSValue fallback returned %q, want quoted sprint output", got)
}
if !strings.HasPrefix(got, "\"") || !strings.HasSuffix(got, "\"") {
t.Fatalf("formatJSValue fallback = %q, want quoted string output", got)
}
}

856
audit_issue2_test.go Normal file
View file

@ -0,0 +1,856 @@
// 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))
}
}
func TestWebviewGoBack_Good_UsesNavigationHistoryAndWaitsForLoad(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
var methods []string
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
switch msg.Method {
case "Page.getNavigationHistory":
target.reply(msg.ID, map[string]any{
"currentIndex": float64(1),
"entries": []any{
map[string]any{"id": float64(11)},
map[string]any{"id": float64(12)},
map[string]any{"id": float64(13)},
},
})
case "Page.navigateToHistoryEntry":
if got, ok := msg.Params["entryId"].(float64); !ok || got != 11 {
t.Fatalf("navigateToHistoryEntry entryId = %v, want 11", msg.Params["entryId"])
}
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
target.replyValue(msg.ID, "complete")
default:
t.Fatalf("unexpected method %q", msg.Method)
}
}
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,
}
if err := wv.GoBack(); err != nil {
t.Fatalf("GoBack returned error: %v", err)
}
if len(methods) != 3 {
t.Fatalf("expected 3 CDP calls, got %d (%v)", len(methods), methods)
}
if methods[0] != "Page.getNavigationHistory" || methods[1] != "Page.navigateToHistoryEntry" || methods[2] != "Runtime.evaluate" {
t.Fatalf("unexpected call sequence: %v", methods)
}
}
func TestWebviewGoForward_Good_UsesNavigationHistoryAndWaitsForLoad(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "Page.getNavigationHistory":
target.reply(msg.ID, map[string]any{
"currentIndex": float64(1),
"entries": []any{
map[string]any{"id": float64(11)},
map[string]any{"id": float64(12)},
map[string]any{"id": float64(13)},
},
})
case "Page.navigateToHistoryEntry":
if got, ok := msg.Params["entryId"].(float64); !ok || got != 13 {
t.Fatalf("navigateToHistoryEntry entryId = %v, want 13", msg.Params["entryId"])
}
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
target.replyValue(msg.ID, "complete")
default:
t.Fatalf("unexpected method %q", msg.Method)
}
}
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,
}
if err := wv.GoForward(); err != nil {
t.Fatalf("GoForward returned error: %v", err)
}
}
func TestWebviewEvaluate_Bad_UsesExceptionText(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.writeJSON(cdpResponse{
ID: msg.ID,
Result: map[string]any{
"exceptionDetails": map[string]any{
"text": "ReferenceError: missingValue is not defined",
},
},
})
}
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,
}
if _, err := wv.Evaluate("missingValue"); err == nil || !core.Contains(err.Error(), "ReferenceError: missingValue is not defined") {
t.Fatalf("Evaluate error = %v, want exception text", err)
}
}
func TestAngularHelperGetRouterState_Good_KeepsOnlyStringParams(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, map[string]any{
"url": "/items/123",
"fragment": "details",
"params": map[string]any{
"id": "123",
"active": true,
},
"queryParams": map[string]any{
"page": "2",
"debug": float64(1),
},
})
}
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)
state, err := ah.GetRouterState()
if err != nil {
t.Fatalf("GetRouterState returned error: %v", err)
}
if state.Params["id"] != "123" {
t.Fatalf("unexpected params: %#v", state.Params)
}
if _, ok := state.Params["active"]; ok {
t.Fatalf("expected non-string params to be omitted, got %#v", state.Params)
}
if state.QueryParams["page"] != "2" {
t.Fatalf("unexpected query params: %#v", state.QueryParams)
}
if _, ok := state.QueryParams["debug"]; ok {
t.Fatalf("expected non-string query params to be omitted, got %#v", state.QueryParams)
}
}

565
cdp.go
View file

@ -1,18 +1,37 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"iter" "iter"
"net"
"net/http" "net/http"
"net/url"
"path"
"slices" "slices"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
)
coreerr "forge.lthn.ai/core/go-log" const debugEndpointTimeout = 10 * time.Second
const maxDebugResponseBytes = 1 << 20
const maxCDPMessageBytes = 16 << 20
var (
defaultDebugHTTPClient = &http.Client{
Timeout: debugEndpointTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
errCDPClientClosed = core.NewError("cdp client closed")
) )
// CDPClient handles communication with Chrome DevTools Protocol via WebSocket. // CDPClient handles communication with Chrome DevTools Protocol via WebSocket.
@ -20,21 +39,24 @@ type CDPClient struct {
mu sync.RWMutex mu sync.RWMutex
conn *websocket.Conn conn *websocket.Conn
debugURL string debugURL string
debugHTTPURL *url.URL
wsURL string wsURL string
// Message tracking // Message tracking
msgID atomic.Int64 messageID atomic.Int64
pending map[int64]chan *cdpResponse pending map[int64]chan *cdpResponse
pendMu sync.Mutex pendingMu sync.Mutex
// Event handlers // Event handlers
handlers map[string][]func(map[string]any) handlers map[string][]func(map[string]any)
handMu sync.RWMutex handlersMu sync.RWMutex
// Lifecycle // Lifecycle
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
done chan struct{} done chan struct{}
closeOnce sync.Once
closeErr error
} }
// cdpMessage represents a CDP protocol message. // cdpMessage represents a CDP protocol message.
@ -76,51 +98,41 @@ type TargetInfo struct {
// NewCDPClient creates a new CDP client connected to the given debug URL. // NewCDPClient creates a new CDP client connected to the given debug URL.
// The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222). // The debug URL should be the Chrome DevTools HTTP endpoint (e.g., http://localhost:9222).
func NewCDPClient(debugURL string) (*CDPClient, error) { func NewCDPClient(debugURL string) (*CDPClient, error) {
// Get available targets debugHTTPURL, err := parseDebugURL(debugURL)
resp, err := http.Get(debugURL + "/json") 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, debugHTTPURL)
if err != nil { if err != nil {
return nil, coreerr.E("CDPClient.New", "failed to get targets", err) 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 // Find a page target
var wsURL string var wsURL string
for _, t := range targets { for _, t := range targets {
if t.Type == "page" && t.WebSocketDebuggerURL != "" { if t.Type == "page" && t.WebSocketDebuggerURL != "" {
wsURL = t.WebSocketDebuggerURL wsURL, err = validateTargetWebSocketURL(debugHTTPURL, t.WebSocketDebuggerURL)
if err != nil {
return nil, coreerr.E("CDPClient.New", "invalid target WebSocket URL", err)
}
break break
} }
} }
if wsURL == "" { if wsURL == "" {
// Try to create a new target newTarget, err := createTargetAt(ctx, debugHTTPURL, "")
resp, err := http.Get(debugURL + "/json/new")
if err != nil { if err != nil {
return nil, coreerr.E("CDPClient.New", "no page targets found and failed to create new", err) return nil, coreerr.E("CDPClient.New", "no page targets found and failed to create new", err)
} }
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body) wsURL, err = validateTargetWebSocketURL(debugHTTPURL, newTarget.WebSocketDebuggerURL)
if err != nil { 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 == "" { if wsURL == "" {
@ -132,53 +144,41 @@ func NewCDPClient(debugURL string) (*CDPClient, error) {
if err != nil { if err != nil {
return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err) return nil, coreerr.E("CDPClient.New", "failed to connect to WebSocket", err)
} }
conn.SetReadLimit(maxCDPMessageBytes)
ctx, cancel := context.WithCancel(context.Background()) return newCDPClient(debugHTTPURL, wsURL, conn), nil
client := &CDPClient{
conn: conn,
debugURL: debugURL,
wsURL: wsURL,
pending: make(map[int64]chan *cdpResponse),
handlers: make(map[string][]func(map[string]any)),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
// Start message reader
go client.readLoop()
return client, nil
} }
// Close closes the CDP connection. // Close closes the CDP connection.
func (c *CDPClient) Close() error { func (c *CDPClient) Close() error {
c.cancel() c.close(errCDPClientClosed)
<-c.done // Wait for read loop to finish <-c.done
return c.conn.Close() if c.closeErr != nil {
return coreerr.E("CDPClient.Close", "failed to close WebSocket", c.closeErr)
}
return nil
} }
// Call sends a CDP method call and waits for the response. // Call sends a CDP method call and waits for the response.
func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error) { func (c *CDPClient) Call(ctx context.Context, method string, params map[string]any) (map[string]any, error) {
id := c.msgID.Add(1) id := c.messageID.Add(1)
msg := cdpMessage{ msg := cdpMessage{
ID: id, ID: id,
Method: method, Method: method,
Params: params, Params: cloneMapAny(params),
} }
// Register response channel // Register response channel
respCh := make(chan *cdpResponse, 1) respCh := make(chan *cdpResponse, 1)
c.pendMu.Lock() c.pendingMu.Lock()
c.pending[id] = respCh c.pending[id] = respCh
c.pendMu.Unlock() c.pendingMu.Unlock()
defer func() { defer func() {
c.pendMu.Lock() c.pendingMu.Lock()
delete(c.pending, id) delete(c.pending, id)
c.pendMu.Unlock() c.pendingMu.Unlock()
}() }()
// Send message // Send message
@ -193,6 +193,8 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case <-c.ctx.Done():
return nil, coreerr.E("CDPClient.Call", "client closed", errCDPClientClosed)
case resp := <-respCh: case resp := <-respCh:
if resp.Error != nil { if resp.Error != nil {
return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil) return nil, coreerr.E("CDPClient.Call", resp.Error.Message, nil)
@ -203,8 +205,8 @@ func (c *CDPClient) Call(ctx context.Context, method string, params map[string]a
// OnEvent registers a handler for CDP events. // OnEvent registers a handler for CDP events.
func (c *CDPClient) OnEvent(method string, handler func(map[string]any)) { func (c *CDPClient) OnEvent(method string, handler func(map[string]any)) {
c.handMu.Lock() c.handlersMu.Lock()
defer c.handMu.Unlock() defer c.handlersMu.Unlock()
c.handlers[method] = append(c.handlers[method], handler) c.handlers[method] = append(c.handlers[method], handler)
} }
@ -213,39 +215,43 @@ func (c *CDPClient) readLoop() {
defer close(c.done) defer close(c.done)
for { for {
select {
case <-c.ctx.Done():
return
default:
}
_, data, err := c.conn.ReadMessage() _, data, err := c.conn.ReadMessage()
if err != nil { if err != nil {
// Check if context was cancelled if c.ctx.Err() != nil {
select {
case <-c.ctx.Done():
return return
default: }
// Log error but continue (could be temporary) if isTerminalReadError(err) {
c.close(err)
return
}
var netErr net.Error
if core.As(err, &netErr) && netErr.Timeout() {
continue continue
} }
c.close(err)
return
} }
// Try to parse as response // Try to parse as response
var resp cdpResponse var resp cdpResponse
if err := json.Unmarshal(data, &resp); err == nil && resp.ID > 0 { if r := core.JSONUnmarshal(data, &resp); r.OK && resp.ID > 0 {
c.pendMu.Lock() c.pendingMu.Lock()
if ch, ok := c.pending[resp.ID]; ok { if ch, ok := c.pending[resp.ID]; ok {
respCopy := resp respCopy := resp
ch <- &respCopy select {
case ch <- &respCopy:
default:
} }
c.pendMu.Unlock() }
c.pendingMu.Unlock()
continue continue
} }
// Try to parse as event // Try to parse as event
var event cdpEvent var event cdpEvent
if err := json.Unmarshal(data, &event); err == nil && event.Method != "" { if r := core.JSONUnmarshal(data, &event); r.OK && event.Method != "" {
c.dispatchEvent(event.Method, event.Params) c.dispatchEvent(event.Method, event.Params)
} }
} }
@ -253,13 +259,14 @@ func (c *CDPClient) readLoop() {
// dispatchEvent dispatches an event to registered handlers. // dispatchEvent dispatches an event to registered handlers.
func (c *CDPClient) dispatchEvent(method string, params map[string]any) { func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
c.handMu.RLock() c.handlersMu.RLock()
handlers := slices.Clone(c.handlers[method]) handlers := slices.Clone(c.handlers[method])
c.handMu.RUnlock() c.handlersMu.RUnlock()
for _, handler := range handlers { for _, handler := range handlers {
// Call handler in goroutine to avoid blocking // Call handler in goroutine to avoid blocking
go handler(params) handlerParams := cloneMapAny(params)
go handler(handlerParams)
} }
} }
@ -267,7 +274,7 @@ func (c *CDPClient) dispatchEvent(method string, params map[string]any) {
func (c *CDPClient) Send(method string, params map[string]any) error { func (c *CDPClient) Send(method string, params map[string]any) error {
msg := cdpMessage{ msg := cdpMessage{
Method: method, Method: method,
Params: params, Params: cloneMapAny(params),
} }
c.mu.Lock() c.mu.Lock()
@ -287,83 +294,72 @@ func (c *CDPClient) WebSocketURL() string {
// NewTab creates a new browser tab and returns a new CDPClient connected to it. // NewTab creates a new browser tab and returns a new CDPClient connected to it.
func (c *CDPClient) NewTab(url string) (*CDPClient, error) { func (c *CDPClient) NewTab(url string) (*CDPClient, error) {
endpoint := c.debugURL + "/json/new" ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
if url != "" { defer cancel()
endpoint += "?" + url
}
resp, err := http.Get(endpoint) target, err := createTargetAt(ctx, c.debugHTTPURL, url)
if err != nil { if err != nil {
return nil, coreerr.E("CDPClient.NewTab", "failed to create new tab", err) return nil, coreerr.E("CDPClient.NewTab", "failed to create new tab", err)
} }
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 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 == "" { if target.WebSocketDebuggerURL == "" {
return nil, coreerr.E("CDPClient.NewTab", "no WebSocket URL for new tab", nil) return nil, coreerr.E("CDPClient.NewTab", "no WebSocket URL for new tab", nil)
} }
wsURL, err := validateTargetWebSocketURL(c.debugHTTPURL, target.WebSocketDebuggerURL)
if err != nil {
return nil, coreerr.E("CDPClient.NewTab", "invalid WebSocket URL for new tab", err)
}
// Connect to new tab // Connect to new tab
conn, _, err := websocket.DefaultDialer.Dial(target.WebSocketDebuggerURL, nil) conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil { if err != nil {
return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err) return nil, coreerr.E("CDPClient.NewTab", "failed to connect to new tab", err)
} }
ctx, cancel := context.WithCancel(context.Background()) return newCDPClient(c.debugHTTPURL, wsURL, conn), nil
client := &CDPClient{
conn: conn,
debugURL: c.debugURL,
wsURL: target.WebSocketDebuggerURL,
pending: make(map[int64]chan *cdpResponse),
handlers: make(map[string][]func(map[string]any)),
ctx: ctx,
cancel: cancel,
done: make(chan struct{}),
}
go client.readLoop()
return client, nil
} }
// CloseTab closes the current tab (target). // CloseTab closes the current tab (target).
func (c *CDPClient) CloseTab() error { func (c *CDPClient) CloseTab() error {
// Extract target ID from WebSocket URL targetID, err := targetIDFromWebSocketURL(c.wsURL)
// Format: ws://host:port/devtools/page/TARGET_ID if err != nil {
// We'll use the Browser.close target API return coreerr.E("CDPClient.CloseTab", "failed to determine target ID", err)
}
defer func() {
_ = c.Close()
}()
ctx := context.Background() ctx, cancel := context.WithTimeout(c.ctx, debugEndpointTimeout)
_, err := c.Call(ctx, "Browser.close", nil) defer cancel()
return err
result, err := c.Call(ctx, "Target.closeTarget", map[string]any{
"targetId": targetID,
})
if err != nil {
return coreerr.E("CDPClient.CloseTab", "failed to close target", err)
}
if success, ok := result["success"].(bool); ok && !success {
return coreerr.E("CDPClient.CloseTab", "target close was not acknowledged", nil)
}
return nil
} }
// ListTargets returns all available targets. // ListTargets returns all available targets.
func ListTargets(debugURL string) ([]TargetInfo, error) { func ListTargets(debugURL string) ([]TargetInfo, error) {
resp, err := http.Get(debugURL + "/json") debugHTTPURL, 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, debugHTTPURL)
if err != nil { if err != nil {
return nil, coreerr.E("ListTargets", "failed to get targets", err) 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 return targets, nil
} }
@ -385,21 +381,312 @@ func ListTargetsAll(debugURL string) iter.Seq[TargetInfo] {
// GetVersion returns Chrome version information. // GetVersion returns Chrome version information.
func GetVersion(debugURL string) (map[string]string, error) { func GetVersion(debugURL string) (map[string]string, error) {
resp, err := http.Get(debugURL + "/json/version") debugHTTPURL, 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, debugHTTPURL, "/json/version", "")
if err != nil { if err != nil {
return nil, coreerr.E("GetVersion", "failed to get version", err) return nil, coreerr.E("GetVersion", "failed to get version", 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)
}
var version map[string]string var version map[string]string
if err := json.Unmarshal(body, &version); err != nil { if r := core.JSONUnmarshal(body, &version); !r.OK {
return nil, coreerr.E("GetVersion", "failed to parse version", err) return nil, coreerr.E("GetVersion", "failed to parse version", nil)
} }
return version, nil return version, nil
} }
func newCDPClient(debugHTTPURL *url.URL, wsURL string, conn *websocket.Conn) *CDPClient {
ctx, cancel := context.WithCancel(context.Background())
baseCopy := *debugHTTPURL
conn.SetReadLimit(maxCDPMessageBytes)
client := &CDPClient{
conn: conn,
debugURL: canonicalDebugURL(&baseCopy),
debugHTTPURL: &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)
}
if !isLoopbackHost(debugURL.Hostname()) {
return nil, coreerr.E("CDPClient.parseDebugURL", "debug URL host must be localhost or loopback", nil)
}
return debugURL, nil
}
func isLoopbackHost(host string) bool {
if host == "" {
return false
}
if core.Lower(host) == "localhost" {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func canonicalDebugURL(debugURL *url.URL) string {
return core.TrimSuffix(debugURL.String(), "/")
}
func doDebugRequest(ctx context.Context, debugHTTPURL *url.URL, endpoint, rawQuery string) ([]byte, error) {
reqURL := *debugHTTPURL
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() }()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, coreerr.E("CDPClient.doDebugRequest", "debug endpoint returned "+resp.Status, nil)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxDebugResponseBytes+1))
if err != nil {
return nil, err
}
if len(body) > maxDebugResponseBytes {
return nil, coreerr.E("CDPClient.doDebugRequest", "debug endpoint response too large", nil)
}
return body, nil
}
func listTargetsAt(ctx context.Context, debugHTTPURL *url.URL) ([]TargetInfo, error) {
body, err := doDebugRequest(ctx, debugHTTPURL, "/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, debugHTTPURL *url.URL, pageURL string) (*TargetInfo, error) {
if pageURL != "" {
if err := validateNavigationURL(pageURL); err != nil {
return nil, coreerr.E("CDPClient.createTargetAt", "invalid page URL", err)
}
}
rawQuery := ""
if pageURL != "" {
rawQuery = url.QueryEscape(pageURL)
}
body, err := doDebugRequest(ctx, debugHTTPURL, "/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(debugHTTPURL *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(debugHTTPURL, wsURL) {
return "", coreerr.E("CDPClient.validateTargetWebSocketURL", "target WebSocket URL must match debug URL host", nil)
}
return wsURL.String(), nil
}
func validateNavigationURL(raw string) error {
navigationURL, err := url.Parse(raw)
if err != nil {
return err
}
switch core.Lower(navigationURL.Scheme) {
case "http", "https":
if navigationURL.Host == "" {
return coreerr.E("CDPClient.validateNavigationURL", "navigation URL host is required", nil)
}
if navigationURL.User != nil {
return coreerr.E("CDPClient.validateNavigationURL", "navigation URL must not include credentials", nil)
}
return nil
case "about":
if raw == "about:blank" {
return nil
}
return coreerr.E("CDPClient.validateNavigationURL", "only about:blank is permitted for non-http navigation", nil)
default:
return coreerr.E("CDPClient.validateNavigationURL", "navigation URL must use http, https, or about:blank", nil)
}
}
func sameEndpointHost(httpURL, wsURL *url.URL) bool {
return core.Lower(httpURL.Hostname()) == core.Lower(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.pendingMu.Lock()
defer c.pendingMu.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
}
}

425
cdp_test.go Normal file
View file

@ -0,0 +1,425 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gorilla/websocket"
)
func newConnectedCDPClient(t *testing.T, target *fakeCDPTarget) *CDPClient {
t.Helper()
client, err := NewCDPClient(target.server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
t.Cleanup(func() {
_ = client.Close()
})
return client
}
func TestCdp_parseDebugURL_Good(t *testing.T) {
tests := []string{
"http://localhost:9222",
"http://127.0.0.1:9222",
"http://[::1]:9222",
"https://localhost:9222/",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
got, err := parseDebugURL(raw)
if err != nil {
t.Fatalf("parseDebugURL returned error: %v", err)
}
if got.Scheme != strings.Split(raw, ":")[0] {
t.Fatalf("parseDebugURL scheme = %q, want %q", got.Scheme, strings.Split(raw, ":")[0])
}
if got.Path != "/" {
t.Fatalf("parseDebugURL path = %q, want %q", got.Path, "/")
}
})
}
}
func TestCdp_parseDebugURL_Bad(t *testing.T) {
tests := []string{
"http://example.com:9222",
"http://localhost:9222/json",
"http://localhost:9222?x=1",
"http://localhost:9222#frag",
"http://user:pass@localhost:9222",
"ftp://localhost:9222",
"localhost:9222",
}
for _, raw := range tests {
t.Run(raw, func(t *testing.T) {
if _, err := parseDebugURL(raw); err == nil {
t.Fatalf("parseDebugURL(%q) returned nil error", raw)
}
})
}
}
func TestCdp_validateNavigationURL_Good(t *testing.T) {
for _, raw := range []string{
"http://localhost:8080/path?q=1",
"https://example.com",
"about:blank",
} {
t.Run(raw, func(t *testing.T) {
if err := validateNavigationURL(raw); err != nil {
t.Fatalf("validateNavigationURL returned error: %v", err)
}
})
}
}
func TestCdp_validateNavigationURL_Bad(t *testing.T) {
for _, raw := range []string{
"javascript:alert(1)",
"data:text/html,hello",
"file:///etc/passwd",
"about:srcdoc",
"http://",
"https://user:pass@example.com",
} {
t.Run(raw, func(t *testing.T) {
if err := validateNavigationURL(raw); err == nil {
t.Fatalf("validateNavigationURL(%q) returned nil error", raw)
}
})
}
}
func TestCdp_normalisedPort_Good(t *testing.T) {
tests := []struct {
raw string
want string
}{
{"http://localhost", "80"},
{"ws://localhost", "80"},
{"https://localhost", "443"},
{"wss://localhost", "443"},
{"http://localhost:1234", "1234"},
{"ws://localhost:5678", "5678"},
}
for _, tc := range tests {
t.Run(tc.raw, func(t *testing.T) {
u, err := url.Parse(tc.raw)
if err != nil {
t.Fatalf("url.Parse returned error: %v", err)
}
if got := normalisedPort(u); got != tc.want {
t.Fatalf("normalisedPort(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestCdp_normalisedPort_Ugly(t *testing.T) {
u, err := url.Parse("ftp://localhost")
if err != nil {
t.Fatalf("url.Parse returned error: %v", err)
}
if got := normalisedPort(u); got != "" {
t.Fatalf("normalisedPort(ftp://localhost) = %q, want empty", got)
}
}
func TestCdp_targetIDFromWebSocketURL_Good(t *testing.T) {
got, err := targetIDFromWebSocketURL("ws://localhost:9222/devtools/page/target-1")
if err != nil {
t.Fatalf("targetIDFromWebSocketURL returned error: %v", err)
}
if got != "target-1" {
t.Fatalf("targetIDFromWebSocketURL = %q, want %q", got, "target-1")
}
}
func TestCdp_targetIDFromWebSocketURL_Bad(t *testing.T) {
for _, raw := range []string{
"ws://localhost:9222/",
"ws://localhost:9222",
} {
t.Run(raw, func(t *testing.T) {
if _, err := targetIDFromWebSocketURL(raw); err == nil {
t.Fatalf("targetIDFromWebSocketURL(%q) returned nil error", raw)
}
})
}
}
func TestCdp_validateTargetWebSocketURL_Bad(t *testing.T) {
debugURL := mustParseURL(t, "http://localhost:9222")
for _, raw := range []string{
"http://localhost:9222/devtools/page/target-1",
"ws://example.com/devtools/page/target-1",
} {
t.Run(raw, func(t *testing.T) {
if _, err := validateTargetWebSocketURL(debugURL, raw); err == nil {
t.Fatalf("validateTargetWebSocketURL(%q) returned nil error", raw)
}
})
}
}
func TestCdp_isTerminalReadError_Good(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "nil", err: nil, want: false},
{name: "net closed", err: net.ErrClosed, want: true},
{name: "ws close sent", err: websocket.ErrCloseSent, want: true},
{name: "close error", err: &websocket.CloseError{Code: 1000, Text: "bye"}, want: true},
{name: "other", err: context.DeadlineExceeded, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isTerminalReadError(tc.err); got != tc.want {
t.Fatalf("isTerminalReadError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
func TestCdp_cloneHelpers_Good(t *testing.T) {
original := map[string]any{
"nested": map[string]any{"count": float64(1)},
"items": []any{map[string]any{"id": "alpha"}},
"value": "original",
}
cloned := cloneMapAny(original)
cloned["value"] = "changed"
cloned["nested"].(map[string]any)["count"] = float64(2)
cloned["items"].([]any)[0].(map[string]any)["id"] = "beta"
if got := original["value"]; got != "original" {
t.Fatalf("original scalar mutated: %v", got)
}
if got := original["nested"].(map[string]any)["count"]; got != float64(1) {
t.Fatalf("original nested map mutated: %v", got)
}
if got := original["items"].([]any)[0].(map[string]any)["id"]; got != "alpha" {
t.Fatalf("original nested slice mutated: %v", got)
}
if cloneMapAny(nil) != nil {
t.Fatal("cloneMapAny(nil) = non-nil")
}
if cloneSliceAny(nil) != nil {
t.Fatal("cloneSliceAny(nil) = non-nil")
}
if got := cloneAny(original).(map[string]any)["value"]; got != "original" {
t.Fatalf("cloneAny(map) = %v, want original value", got)
}
}
func TestCdp_ListTargets_Good(t *testing.T) {
server := newFakeCDPServer(t)
targets, err := ListTargets(server.DebugURL())
if err != nil {
t.Fatalf("ListTargets returned error: %v", err)
}
if len(targets) != 1 {
t.Fatalf("ListTargets returned %d targets, want 1", len(targets))
}
if targets[0].Type != "page" {
t.Fatalf("ListTargets type = %q, want page", targets[0].Type)
}
got := make([]TargetInfo, 0)
for target := range ListTargetsAll(server.DebugURL()) {
got = append(got, target)
}
if len(got) != 1 {
t.Fatalf("ListTargetsAll yielded %d targets, want 1", len(got))
}
}
func TestCdp_GetVersion_Good(t *testing.T) {
server := newFakeCDPServer(t)
version, err := GetVersion(server.DebugURL())
if err != nil {
t.Fatalf("GetVersion returned error: %v", err)
}
if got := version["Browser"]; got != "Chrome/123.0" {
t.Fatalf("GetVersion Browser = %q, want Chrome/123.0", got)
}
}
func TestCdp_NewCDPClient_Good_AutoCreatesTarget(t *testing.T) {
server := newFakeCDPServer(t)
server.mu.Lock()
server.targets = make(map[string]*fakeCDPTarget)
server.nextTarget = 0
server.mu.Unlock()
client, err := NewCDPClient(server.DebugURL())
if err != nil {
t.Fatalf("NewCDPClient returned error: %v", err)
}
t.Cleanup(func() {
_ = client.Close()
})
if client.DebugURL() != server.DebugURL() {
t.Fatalf("DebugURL() = %q, want %q", client.DebugURL(), server.DebugURL())
}
if client.WebSocketURL() == "" {
t.Fatal("WebSocketURL() returned empty string")
}
}
func TestCdp_NewCDPClient_Bad_RejectsInvalidDebugURL(t *testing.T) {
_, err := NewCDPClient("http://example.com:9222")
if err == nil {
t.Fatal("NewCDPClient succeeded for remote host")
}
}
func TestCdp_Send_Good(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
client := newConnectedCDPClient(t, target)
done := make(chan cdpMessage, 1)
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
done <- msg
}
if err := client.Send("Page.enable", map[string]any{"foo": "bar"}); err != nil {
t.Fatalf("Send returned error: %v", err)
}
select {
case msg := <-done:
if msg.Method != "Page.enable" {
t.Fatalf("Send method = %q, want Page.enable", msg.Method)
}
if got := msg.Params["foo"]; got != "bar" {
t.Fatalf("Send param foo = %v, want bar", got)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for sent message")
}
}
func TestCdp_NewTab_Good(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
client := newConnectedCDPClient(t, target)
tab, err := client.NewTab("about:blank")
if err != nil {
t.Fatalf("NewTab returned error: %v", err)
}
t.Cleanup(func() {
_ = tab.Close()
})
if tab.WebSocketURL() == "" {
t.Fatal("NewTab returned empty WebSocket URL")
}
}
func TestCdp_CloseTab_Bad_TargetCloseNotAcknowledged(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)
}
target.reply(msg.ID, map[string]any{"success": false})
}
client := newConnectedCDPClient(t, target)
if err := client.CloseTab(); err == nil {
t.Fatal("CloseTab succeeded without target close acknowledgement")
}
}
func TestCdp_failPending_Good(t *testing.T) {
c1 := make(chan *cdpResponse, 1)
c2 := make(chan *cdpResponse, 1)
client := &CDPClient{
pending: map[int64]chan *cdpResponse{
1: c1,
2: c2,
},
}
client.failPending(errors.New("boom"))
for i, ch := range []chan *cdpResponse{c1, c2} {
select {
case resp := <-ch:
if resp.Error == nil || resp.Error.Message != "boom" {
t.Fatalf("pending response %d = %#v, want boom error", i+1, resp)
}
default:
t.Fatalf("pending response %d was not delivered", i+1)
}
}
}
func TestCdp_createTargetAt_Good(t *testing.T) {
server := newFakeCDPServer(t)
target, err := createTargetAt(context.Background(), mustParseURL(t, server.DebugURL()), "about:blank")
if err != nil {
t.Fatalf("createTargetAt returned error: %v", err)
}
if target == nil || target.WebSocketDebuggerURL == "" {
t.Fatalf("createTargetAt returned %#v", target)
}
}
func TestCdp_createTargetAt_Bad_InvalidPageURL(t *testing.T) {
server := newFakeCDPServer(t)
if _, err := createTargetAt(context.Background(), mustParseURL(t, server.DebugURL()), "javascript:alert(1)"); err == nil {
t.Fatal("createTargetAt succeeded with a dangerous page URL")
}
}
func TestCdp_doDebugRequest_Bad_HTTPStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTeapot)
}))
t.Cleanup(server.Close)
debugURL, err := parseDebugURL(server.URL)
if err != nil {
t.Fatalf("parseDebugURL returned error: %v", err)
}
if _, err := doDebugRequest(context.Background(), debugURL, "/json", ""); err == nil {
t.Fatal("doDebugRequest returned nil error for non-2xx status")
}
}
func mustParseURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("url.Parse returned error: %v", err)
}
return u
}

View file

@ -1,13 +1,17 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context" "context"
"fmt"
"iter" "iter"
"slices" "slices"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// ConsoleWatcher provides advanced console message watching capabilities. // ConsoleWatcher provides advanced console message watching capabilities.
@ -17,34 +21,174 @@ type ConsoleWatcher struct {
messages []ConsoleMessage messages []ConsoleMessage
filters []ConsoleFilter filters []ConsoleFilter
limit int limit int
handlers []ConsoleHandler handlers []consoleHandlerRegistration
waiters []consoleMessageWaiter
nextHandlerID atomic.Int64
} }
// ConsoleFilter filters console messages. // ConsoleFilter filters console messages.
type ConsoleFilter struct { type ConsoleFilter struct {
Type string // Filter by type (log, warn, error, info, debug), empty for all Type string // Exact message type match, empty for all
Pattern string // Filter by text pattern (substring match) Pattern string // Filter by text pattern (substring match)
} }
// ConsoleHandler is called when a matching console message is received. // ConsoleHandler is called when a matching console message is received.
type ConsoleHandler func(msg ConsoleMessage) type ConsoleHandler func(msg ConsoleMessage)
// NewConsoleWatcher creates a new console watcher for the webview. type consoleHandlerRegistration struct {
id int64
handler ConsoleHandler
}
type consoleMessageWaiter struct {
filter ConsoleFilter
ch chan ConsoleMessage
}
// Watch console messages from a Webview while a flow is running.
//
// watcher := webview.NewConsoleWatcher(wv)
// watcher.AddFilter(webview.ConsoleFilter{Type: "error"})
func NewConsoleWatcher(wv *Webview) *ConsoleWatcher { func NewConsoleWatcher(wv *Webview) *ConsoleWatcher {
cw := &ConsoleWatcher{ watcher := &ConsoleWatcher{
wv: wv, wv: wv,
messages: make([]ConsoleMessage, 0, 100), messages: make([]ConsoleMessage, 0, 1000),
filters: make([]ConsoleFilter, 0), filters: make([]ConsoleFilter, 0),
limit: 1000, limit: 1000,
handlers: make([]ConsoleHandler, 0), handlers: make([]consoleHandlerRegistration, 0),
}
if wv == nil || wv.client == nil {
return watcher
} }
// Subscribe to console events from the webview's client // Subscribe to console events from the webview's client
wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) { wv.client.OnEvent("Runtime.consoleAPICalled", func(params map[string]any) {
cw.handleConsoleEvent(params) watcher.handleConsoleEvent(params)
}) })
return cw return watcher
}
// normalizeConsoleType converts CDP event types to the package's stored value.
//
// It accepts legacy warning aliases and stores the compact warn form used by
// the existing console message contract.
func normalizeConsoleType(raw string) string {
normalized := strings.ToLower(core.Trim(core.Sprint(raw)))
if normalized == "warn" || normalized == "warning" {
return "warn"
}
return normalized
}
// canonicalConsoleType returns the RFC-canonical console type name.
func canonicalConsoleType(raw string) string {
normalized := strings.ToLower(core.Trim(core.Sprint(raw)))
if normalized == "warn" || normalized == "warning" {
return "warning"
}
return normalized
}
// consoleTextFromArgs extracts message text from Runtime.consoleAPICalled args.
func consoleTextFromArgs(args []any) string {
text := core.NewBuilder()
for i, arg := range args {
if i > 0 {
text.WriteString(" ")
}
text.WriteString(consoleArgText(arg))
}
return text.String()
}
func consoleArgText(arg any) string {
remoteObj, ok := arg.(map[string]any)
if !ok {
return consoleValueToString(arg)
}
if value, ok := remoteObj["value"]; ok {
return consoleValueToString(value)
}
if desc, ok := remoteObj["description"].(string); ok && desc != "" {
return desc
}
if preview, ok := remoteObj["preview"].(map[string]any); ok {
if description, ok := preview["description"].(string); ok && description != "" {
return description
}
}
if preview, ok := remoteObj["preview"].(map[string]any); ok {
if value, ok := preview["value"].(string); ok && value != "" {
return value
}
}
if r := core.JSONMarshal(remoteObj); r.OK {
if encoded, ok := r.Value.([]byte); ok {
return string(encoded)
}
}
return ""
}
func consoleValueToString(value any) string {
if value == nil {
return "null"
}
if valueStr, ok := value.(string); ok {
return valueStr
}
if r := core.JSONMarshal(value); r.OK {
if encoded, ok := r.Value.([]byte); ok {
return string(encoded)
}
}
return core.Sprint(value)
}
func consoleCaptureTimestamp() time.Time {
return time.Now()
}
func trimConsoleMessages(messages []ConsoleMessage, limit int) []ConsoleMessage {
if limit < 0 {
limit = 0
}
if overflow := len(messages) - limit; overflow > 0 {
copy(messages, messages[overflow:])
messages = messages[:len(messages)-overflow]
}
return messages
}
func runtimeExceptionText(exceptionDetails map[string]any) string {
if exception, ok := exceptionDetails["exception"].(map[string]any); ok {
if description, ok := exception["description"].(string); ok && description != "" {
return description
}
}
if text, ok := exceptionDetails["text"].(string); ok && text != "" {
return text
}
return "JavaScript error"
}
func runtimeExceptionError(scope string, exceptionDetails map[string]any) error {
return coreerr.E(scope, runtimeExceptionText(exceptionDetails), nil)
} }
// AddFilter adds a filter to the watcher. // AddFilter adds a filter to the watcher.
@ -63,15 +207,51 @@ func (cw *ConsoleWatcher) ClearFilters() {
// AddHandler adds a handler for console messages. // AddHandler adds a handler for console messages.
func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) { func (cw *ConsoleWatcher) AddHandler(handler ConsoleHandler) {
cw.mu.Lock() cw.addHandler(handler)
defer cw.mu.Unlock()
cw.handlers = append(cw.handlers, handler)
} }
// SetLimit sets the maximum number of messages to retain. func (cw *ConsoleWatcher) addHandler(handler ConsoleHandler) int64 {
cw.mu.Lock()
defer cw.mu.Unlock()
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
}
}
}
func (cw *ConsoleWatcher) removeWaiter(ch chan ConsoleMessage) {
cw.mu.Lock()
defer cw.mu.Unlock()
for i, waiter := range cw.waiters {
if waiter.ch == ch {
cw.waiters = slices.Delete(cw.waiters, i, i+1)
return
}
}
}
// SetLimit replaces the retention limit for future appends.
func (cw *ConsoleWatcher) SetLimit(limit int) { func (cw *ConsoleWatcher) SetLimit(limit int) {
cw.mu.Lock() cw.mu.Lock()
defer cw.mu.Unlock() defer cw.mu.Unlock()
if limit < 0 {
limit = 0
}
cw.limit = limit cw.limit = limit
} }
@ -127,7 +307,7 @@ func (cw *ConsoleWatcher) ErrorsAll() iter.Seq[ConsoleMessage] {
defer cw.mu.RUnlock() defer cw.mu.RUnlock()
for _, msg := range cw.messages { for _, msg := range cw.messages {
if msg.Type == "error" { if canonicalConsoleType(msg.Type) == "error" {
if !yield(msg) { if !yield(msg) {
return return
} }
@ -148,7 +328,7 @@ func (cw *ConsoleWatcher) WarningsAll() iter.Seq[ConsoleMessage] {
defer cw.mu.RUnlock() defer cw.mu.RUnlock()
for _, msg := range cw.messages { for _, msg := range cw.messages {
if msg.Type == "warning" { if canonicalConsoleType(msg.Type) == "warning" {
if !yield(msg) { if !yield(msg) {
return return
} }
@ -166,7 +346,6 @@ func (cw *ConsoleWatcher) Clear() {
// WaitForMessage waits for a message matching the filter. // WaitForMessage waits for a message matching the filter.
func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error) { func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilter) (*ConsoleMessage, error) {
// First check existing messages
cw.mu.RLock() cw.mu.RLock()
for _, msg := range cw.messages { for _, msg := range cw.messages {
if cw.matchesSingleFilter(msg, filter) { if cw.matchesSingleFilter(msg, filter) {
@ -176,29 +355,25 @@ func (cw *ConsoleWatcher) WaitForMessage(ctx context.Context, filter ConsoleFilt
} }
cw.mu.RUnlock() cw.mu.RUnlock()
// Set up a channel for new messages messageCh := make(chan ConsoleMessage, 1)
msgCh := make(chan ConsoleMessage, 1)
handler := func(msg ConsoleMessage) {
if cw.matchesSingleFilter(msg, filter) {
select {
case msgCh <- msg:
default:
}
}
}
cw.AddHandler(handler)
defer func() {
cw.mu.Lock() cw.mu.Lock()
// Remove handler (simple implementation - in production you'd want a handle-based removal) for _, msg := range cw.messages {
cw.handlers = cw.handlers[:len(cw.handlers)-1] if cw.matchesSingleFilter(msg, filter) {
cw.mu.Unlock() cw.mu.Unlock()
}() return &msg, nil
}
}
cw.waiters = append(cw.waiters, consoleMessageWaiter{
filter: filter,
ch: messageCh,
})
cw.mu.Unlock()
defer cw.removeWaiter(messageCh)
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
case msg := <-msgCh: case msg := <-messageCh:
return &msg, nil return &msg, nil
} }
} }
@ -214,7 +389,7 @@ func (cw *ConsoleWatcher) HasErrors() bool {
defer cw.mu.RUnlock() defer cw.mu.RUnlock()
for _, msg := range cw.messages { for _, msg := range cw.messages {
if msg.Type == "error" { if canonicalConsoleType(msg.Type) == "error" {
return true return true
} }
} }
@ -235,7 +410,7 @@ func (cw *ConsoleWatcher) ErrorCount() int {
count := 0 count := 0
for _, msg := range cw.messages { for _, msg := range cw.messages {
if msg.Type == "error" { if canonicalConsoleType(msg.Type) == "error" {
count++ count++
} }
} }
@ -244,21 +419,11 @@ func (cw *ConsoleWatcher) ErrorCount() int {
// handleConsoleEvent processes incoming console events. // handleConsoleEvent processes incoming console events.
func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) { func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
msgType, _ := params["type"].(string) msgType := canonicalConsoleType(core.Sprint(params["type"]))
// Extract args // Extract args
args, _ := params["args"].([]any) args, _ := params["args"].([]any)
var text strings.Builder text := consoleTextFromArgs(args)
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))
}
}
}
// Extract stack trace info // Extract stack trace info
stackTrace, _ := params["stackTrace"].(map[string]any) stackTrace, _ := params["stackTrace"].(map[string]any)
@ -276,8 +441,8 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
msg := ConsoleMessage{ msg := ConsoleMessage{
Type: msgType, Type: msgType,
Text: text.String(), Text: text,
Timestamp: time.Now(), Timestamp: consoleCaptureTimestamp(),
URL: url, URL: url,
Line: line, Line: line,
Column: column, Column: column,
@ -290,23 +455,34 @@ func (cw *ConsoleWatcher) handleConsoleEvent(params map[string]any) {
func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) { func (cw *ConsoleWatcher) addMessage(msg ConsoleMessage) {
cw.mu.Lock() cw.mu.Lock()
// Enforce limit
if len(cw.messages) >= cw.limit {
cw.messages = cw.messages[len(cw.messages)-cw.limit+100:]
}
cw.messages = append(cw.messages, msg) cw.messages = append(cw.messages, msg)
cw.messages = trimConsoleMessages(cw.messages, cw.limit)
// Copy handlers to call outside lock // Copy handlers to call outside lock
handlers := slices.Clone(cw.handlers) handlers := slices.Clone(cw.handlers)
waiters := slices.Clone(cw.waiters)
cw.mu.Unlock() cw.mu.Unlock()
// Call handlers for _, waiter := range waiters {
for _, handler := range handlers { if cw.matchesSingleFilter(msg, waiter.filter) {
handler(msg) select {
case waiter.ch <- msg:
default:
}
} }
} }
// matchesFilter checks if a message matches any filter. // Call handlers
for _, registration := range handlers {
registration.handler(msg)
}
}
// matchesFilter checks whether a message matches the active filter set.
//
// When no filters are configured, every message matches. When filters exist,
// the watcher uses OR semantics: a message is included as soon as it matches
// one configured filter.
func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool { func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool {
if len(cw.filters) == 0 { if len(cw.filters) == 0 {
return true return true
@ -321,9 +497,13 @@ func (cw *ConsoleWatcher) matchesFilter(msg ConsoleMessage) bool {
// matchesSingleFilter checks if a message matches a specific filter. // matchesSingleFilter checks if a message matches a specific filter.
func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter ConsoleFilter) bool { func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter ConsoleFilter) bool {
if filter.Type != "" && msg.Type != filter.Type { if filter.Type != "" {
filterType := canonicalConsoleType(filter.Type)
messageType := canonicalConsoleType(msg.Type)
if messageType != filterType {
return false return false
} }
}
if filter.Pattern != "" { if filter.Pattern != "" {
// Simple substring match // Simple substring match
if !containsString(msg.Text, filter.Pattern) { if !containsString(msg.Text, filter.Pattern) {
@ -333,6 +513,10 @@ func (cw *ConsoleWatcher) matchesSingleFilter(msg ConsoleMessage, filter Console
return true return true
} }
func isWarningType(messageType string) bool {
return canonicalConsoleType(messageType) == "warning"
}
// containsString checks if s contains substr (case-sensitive). // containsString checks if s contains substr (case-sensitive).
func containsString(s, substr string) bool { func containsString(s, substr string) bool {
return len(substr) == 0 || (len(s) >= len(substr) && findString(s, substr) >= 0) return len(substr) == 0 || (len(s) >= len(substr) && findString(s, substr) >= 0)
@ -363,15 +547,35 @@ type ExceptionWatcher struct {
mu sync.RWMutex mu sync.RWMutex
wv *Webview wv *Webview
exceptions []ExceptionInfo exceptions []ExceptionInfo
handlers []func(ExceptionInfo) limit int
handlers []exceptionHandlerRegistration
waiters []exceptionWaiter
nextHandlerID atomic.Int64
} }
// NewExceptionWatcher creates a new exception watcher. type exceptionHandlerRegistration struct {
id int64
handler func(ExceptionInfo)
}
type exceptionWaiter struct {
ch chan ExceptionInfo
}
// Capture Runtime.exceptionThrown events from the active page.
//
// watcher := webview.NewExceptionWatcher(wv)
// exc, err := watcher.WaitForException(ctx)
func NewExceptionWatcher(wv *Webview) *ExceptionWatcher { func NewExceptionWatcher(wv *Webview) *ExceptionWatcher {
ew := &ExceptionWatcher{ ew := &ExceptionWatcher{
wv: wv, wv: wv,
exceptions: make([]ExceptionInfo, 0), exceptions: make([]ExceptionInfo, 0),
handlers: make([]func(ExceptionInfo), 0), limit: 1000,
handlers: make([]exceptionHandlerRegistration, 0),
}
if wv == nil || wv.client == nil {
return ew
} }
// Subscribe to exception events // Subscribe to exception events
@ -424,14 +628,46 @@ func (ew *ExceptionWatcher) Count() int {
// AddHandler adds a handler for exceptions. // AddHandler adds a handler for exceptions.
func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) { func (ew *ExceptionWatcher) AddHandler(handler func(ExceptionInfo)) {
ew.addHandler(handler)
}
func (ew *ExceptionWatcher) addHandler(handler func(ExceptionInfo)) int64 {
ew.mu.Lock() ew.mu.Lock()
defer ew.mu.Unlock() defer ew.mu.Unlock()
ew.handlers = append(ew.handlers, handler) id := ew.nextHandlerID.Add(1)
ew.handlers = append(ew.handlers, exceptionHandlerRegistration{
id: id,
handler: handler,
})
return id
}
func (ew *ExceptionWatcher) removeHandler(id int64) {
ew.mu.Lock()
defer ew.mu.Unlock()
for i, registration := range ew.handlers {
if registration.id == id {
ew.handlers = slices.Delete(ew.handlers, i, i+1)
return
}
}
}
func (ew *ExceptionWatcher) removeWaiter(ch chan ExceptionInfo) {
ew.mu.Lock()
defer ew.mu.Unlock()
for i, waiter := range ew.waiters {
if waiter.ch == ch {
ew.waiters = slices.Delete(ew.waiters, i, i+1)
return
}
}
} }
// WaitForException waits for an exception to be thrown. // WaitForException waits for an exception to be thrown.
func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error) { func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInfo, error) {
// Check existing exceptions first
ew.mu.RLock() ew.mu.RLock()
if len(ew.exceptions) > 0 { if len(ew.exceptions) > 0 {
exc := ew.exceptions[len(ew.exceptions)-1] exc := ew.exceptions[len(ew.exceptions)-1]
@ -440,21 +676,16 @@ func (ew *ExceptionWatcher) WaitForException(ctx context.Context) (*ExceptionInf
} }
ew.mu.RUnlock() ew.mu.RUnlock()
// Set up a channel for new exceptions
excCh := make(chan ExceptionInfo, 1) excCh := make(chan ExceptionInfo, 1)
handler := func(exc ExceptionInfo) {
select {
case excCh <- exc:
default:
}
}
ew.AddHandler(handler)
defer func() {
ew.mu.Lock() ew.mu.Lock()
ew.handlers = ew.handlers[:len(ew.handlers)-1] if len(ew.exceptions) > 0 {
exc := ew.exceptions[len(ew.exceptions)-1]
ew.mu.Unlock() ew.mu.Unlock()
}() return &exc, nil
}
ew.waiters = append(ew.waiters, exceptionWaiter{ch: excCh})
ew.mu.Unlock()
defer ew.removeWaiter(excCh)
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -477,7 +708,7 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
url, _ := exceptionDetails["url"].(string) url, _ := exceptionDetails["url"].(string)
// Extract stack trace // Extract stack trace
var stackTrace strings.Builder stackTrace := core.NewBuilder()
if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok { if st, ok := exceptionDetails["stackTrace"].(map[string]any); ok {
if frames, ok := st["callFrames"].([]any); ok { if frames, ok := st["callFrames"].([]any); ok {
for _, f := range frames { for _, f := range frames {
@ -486,18 +717,14 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
frameURL, _ := frame["url"].(string) frameURL, _ := frame["url"].(string)
frameLine, _ := frame["lineNumber"].(float64) frameLine, _ := frame["lineNumber"].(float64)
frameCol, _ := frame["columnNumber"].(float64) frameCol, _ := frame["columnNumber"].(float64)
stackTrace.WriteString(fmt.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol))) stackTrace.WriteString(core.Sprintf(" at %s (%s:%d:%d)\n", funcName, frameURL, int(frameLine), int(frameCol)))
} }
} }
} }
} }
// Try to get exception value description // Try to get exception value description
if exc, ok := exceptionDetails["exception"].(map[string]any); ok { text = runtimeExceptionText(exceptionDetails)
if desc, ok := exc["description"].(string); ok && desc != "" {
text = desc
}
}
info := ExceptionInfo{ info := ExceptionInfo{
Text: text, Text: text,
@ -510,21 +737,30 @@ func (ew *ExceptionWatcher) handleException(params map[string]any) {
ew.mu.Lock() ew.mu.Lock()
ew.exceptions = append(ew.exceptions, info) ew.exceptions = append(ew.exceptions, info)
ew.exceptions = trimExceptionInfos(ew.exceptions, ew.limit)
handlers := slices.Clone(ew.handlers) handlers := slices.Clone(ew.handlers)
waiters := slices.Clone(ew.waiters)
ew.mu.Unlock() ew.mu.Unlock()
for _, waiter := range waiters {
select {
case waiter.ch <- info:
default:
}
}
// Call handlers // Call handlers
for _, handler := range handlers { for _, registration := range handlers {
handler(info) registration.handler(info)
} }
} }
// FormatConsoleOutput formats console messages for display. // FormatConsoleOutput formats console messages for display.
func FormatConsoleOutput(messages []ConsoleMessage) string { func FormatConsoleOutput(messages []ConsoleMessage) string {
var output strings.Builder output := core.NewBuilder()
for _, msg := range messages { for _, msg := range messages {
prefix := "" prefix := ""
switch msg.Type { switch canonicalConsoleType(msg.Type) {
case "error": case "error":
prefix = "[ERROR]" prefix = "[ERROR]"
case "warning": case "warning":
@ -537,7 +773,46 @@ func FormatConsoleOutput(messages []ConsoleMessage) string {
prefix = "[LOG]" prefix = "[LOG]"
} }
timestamp := msg.Timestamp.Format("15:04:05.000") timestamp := msg.Timestamp.Format("15:04:05.000")
output.WriteString(fmt.Sprintf("%s %s %s\n", timestamp, prefix, msg.Text)) output.WriteString(core.Sprintf("%s %s %s\n", timestamp, prefix, sanitizeConsoleText(msg.Text)))
} }
return output.String() return output.String()
} }
func trimExceptionInfos(exceptions []ExceptionInfo, limit int) []ExceptionInfo {
if limit < 0 {
limit = 0
}
if overflow := len(exceptions) - limit; overflow > 0 {
copy(exceptions, exceptions[overflow:])
exceptions = exceptions[:len(exceptions)-overflow]
}
return exceptions
}
func sanitizeConsoleText(text string) string {
var b strings.Builder
b.Grow(len(text))
for _, r := range text {
switch r {
case '\n':
b.WriteString(`\n`)
case '\r':
b.WriteString(`\r`)
case '\t':
b.WriteString(`\t`)
case '\x1b':
b.WriteString(`\x1b`)
default:
if r < 0x20 || r == 0x7f {
b.WriteByte(' ')
continue
}
b.WriteRune(r)
}
}
return b.String()
}

446
console_test.go Normal file
View file

@ -0,0 +1,446 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"strings"
"testing"
"time"
)
func TestConsole_normalizeConsoleType_Good(t *testing.T) {
tests := []struct {
raw string
want string
}{
{raw: "warn", want: "warn"},
{raw: "warning", want: "warn"},
{raw: " WARNING ", want: "warn"},
{raw: "error", want: "error"},
{raw: "info", want: "info"},
}
for _, tc := range tests {
t.Run(tc.raw, func(t *testing.T) {
if got := normalizeConsoleType(tc.raw); got != tc.want {
t.Fatalf("normalizeConsoleType(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
func TestConsole_consoleValueToString_Good(t *testing.T) {
tests := []struct {
name string
val any
want string
}{
{name: "nil", val: nil, want: "null"},
{name: "string", val: "hello", want: "hello"},
{name: "number", val: float64(12), want: "12"},
{name: "bool", val: true, want: "true"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := consoleValueToString(tc.val); got != tc.want {
t.Fatalf("consoleValueToString(%v) = %q, want %q", tc.val, got, tc.want)
}
})
}
}
func TestConsole_consoleArgText_Good(t *testing.T) {
tests := []struct {
name string
arg any
want string
}{
{name: "value", arg: map[string]any{"value": "alpha"}, want: "alpha"},
{name: "description", arg: map[string]any{"description": "bravo"}, want: "bravo"},
{name: "preview description", arg: map[string]any{"preview": map[string]any{"description": "charlie"}}, want: "charlie"},
{name: "preview value", arg: map[string]any{"preview": map[string]any{"value": "delta"}}, want: "delta"},
{name: "plain scalar", arg: 42, want: "42"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := consoleArgText(tc.arg); got != tc.want {
t.Fatalf("consoleArgText(%v) = %q, want %q", tc.arg, got, tc.want)
}
})
}
}
func TestConsole_consoleArgText_Ugly(t *testing.T) {
got := consoleArgText(map[string]any{"value": map[string]any{"nested": true}})
if !strings.Contains(got, `"nested":true`) {
t.Fatalf("consoleArgText fallback JSON = %q, want JSON encoding", got)
}
}
func TestConsole_trimConsoleMessages_Good(t *testing.T) {
messages := []ConsoleMessage{
{Text: "one"},
{Text: "two"},
{Text: "three"},
}
tests := []struct {
name string
limit int
want []string
}{
{name: "no trim", limit: 3, want: []string{"one", "two", "three"}},
{name: "trim to one", limit: 1, want: []string{"three"}},
{name: "zero", limit: 0, want: nil},
{name: "negative", limit: -1, want: nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cloned := append([]ConsoleMessage(nil), messages...)
got := trimConsoleMessages(cloned, tc.limit)
if len(got) != len(tc.want) {
t.Fatalf("trimConsoleMessages len = %d, want %d", len(got), len(tc.want))
}
for i, want := range tc.want {
if got[i].Text != want {
t.Fatalf("trimConsoleMessages[%d] = %q, want %q", i, got[i].Text, want)
}
}
})
}
}
func TestConsole_trimExceptionInfos_Good(t *testing.T) {
exceptions := []ExceptionInfo{
{Text: "one"},
{Text: "two"},
{Text: "three"},
}
tests := []struct {
name string
limit int
want []string
}{
{name: "no trim", limit: 3, want: []string{"one", "two", "three"}},
{name: "trim to one", limit: 1, want: []string{"three"}},
{name: "zero", limit: 0, want: nil},
{name: "negative", limit: -1, want: nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cloned := append([]ExceptionInfo(nil), exceptions...)
got := trimExceptionInfos(cloned, tc.limit)
if len(got) != len(tc.want) {
t.Fatalf("trimExceptionInfos len = %d, want %d", len(got), len(tc.want))
}
for i, want := range tc.want {
if got[i].Text != want {
t.Fatalf("trimExceptionInfos[%d] = %q, want %q", i, got[i].Text, want)
}
}
})
}
}
func TestConsole_sanitizeConsoleText_Good(t *testing.T) {
got := sanitizeConsoleText("line1\nline2\r\t\x1b[31m\x7f")
if !strings.Contains(got, `line1\nline2\r\t\x1b[31m`) {
t.Fatalf("sanitizeConsoleText did not escape control characters: %q", got)
}
if strings.Contains(got, "\x7f") {
t.Fatalf("sanitizeConsoleText kept DEL byte: %q", got)
}
}
func TestConsole_runtimeExceptionText_Good(t *testing.T) {
tests := []struct {
name string
in map[string]any
want string
}{
{name: "description", in: map[string]any{"exception": map[string]any{"description": "stack"}}, want: "stack"},
{name: "text", in: map[string]any{"text": "boom"}, want: "boom"},
{name: "default", in: map[string]any{}, want: "JavaScript error"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := runtimeExceptionText(tc.in); got != tc.want {
t.Fatalf("runtimeExceptionText = %q, want %q", got, tc.want)
}
})
}
}
func TestConsole_NewConsoleWatcher_Good(t *testing.T) {
watcher := NewConsoleWatcher(nil)
if watcher == nil {
t.Fatal("NewConsoleWatcher returned nil")
}
if watcher.Count() != 0 {
t.Fatalf("NewConsoleWatcher count = %d, want 0", watcher.Count())
}
}
func TestConsole_NewConsoleWatcher_Good_SubscribesToClient(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
client := newConnectedCDPClient(t, target)
watcher := NewConsoleWatcher(&Webview{client: client})
target.writeJSON(cdpEvent{
Method: "Runtime.consoleAPICalled",
Params: map[string]any{
"type": "log",
"args": []any{map[string]any{"value": "hello"}},
},
})
time.Sleep(50 * time.Millisecond)
if watcher.Count() != 1 {
t.Fatalf("NewConsoleWatcher subscription count = %d, want 1", watcher.Count())
}
}
func TestConsole_WaitForError_Good(t *testing.T) {
watcher := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 10,
handlers: make([]consoleHandlerRegistration, 0),
}
watcher.addMessage(ConsoleMessage{Type: "warn", Text: "ignore"})
watcher.addMessage(ConsoleMessage{Type: "error", Text: "boom"})
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
msg, err := watcher.WaitForError(ctx)
if err != nil {
t.Fatalf("WaitForError returned error: %v", err)
}
if msg.Text != "boom" {
t.Fatalf("WaitForError text = %q, want boom", msg.Text)
}
}
func TestConsole_WaitForError_Bad(t *testing.T) {
watcher := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 10,
handlers: make([]consoleHandlerRegistration, 0),
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
if _, err := watcher.WaitForError(ctx); err == nil {
t.Fatal("WaitForError succeeded without an error message")
}
}
func TestConsole_WaitForMessage_Bad_TimesOut(t *testing.T) {
watcher := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 10,
handlers: make([]consoleHandlerRegistration, 0),
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := watcher.WaitForMessage(ctx, ConsoleFilter{Type: "error"}); err == nil {
t.Fatal("WaitForMessage succeeded without a matching message")
}
}
func TestConsole_handleConsoleEvent_Good(t *testing.T) {
watcher := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 10,
handlers: make([]consoleHandlerRegistration, 0),
}
watcher.handleConsoleEvent(map[string]any{
"type": "warning",
"args": []any{
map[string]any{"value": "alpha"},
map[string]any{"description": "beta"},
},
"stackTrace": map[string]any{
"callFrames": []any{
map[string]any{
"url": "https://example.com/app.js",
"lineNumber": float64(12),
"columnNumber": float64(34),
},
},
},
})
msgs := watcher.Messages()
if len(msgs) != 1 {
t.Fatalf("handleConsoleEvent stored %d messages, want 1", len(msgs))
}
if msgs[0].Type != "warning" {
t.Fatalf("handleConsoleEvent type = %q, want warning", msgs[0].Type)
}
if msgs[0].Text != "alpha beta" {
t.Fatalf("handleConsoleEvent text = %q, want %q", msgs[0].Text, "alpha beta")
}
if msgs[0].URL != "https://example.com/app.js" || msgs[0].Line != 12 || msgs[0].Column != 34 {
t.Fatalf("handleConsoleEvent stack info = %#v", msgs[0])
}
}
func TestConsole_removeHandler_Good(t *testing.T) {
cw := &ConsoleWatcher{
handlers: []consoleHandlerRegistration{
{id: 1},
{id: 2},
},
}
cw.removeHandler(1)
if len(cw.handlers) != 1 || cw.handlers[0].id != 2 {
t.Fatalf("removeHandler did not remove the requested handler: %#v", cw.handlers)
}
cw.removeHandler(99)
if len(cw.handlers) != 1 || cw.handlers[0].id != 2 {
t.Fatalf("removeHandler changed handlers unexpectedly: %#v", cw.handlers)
}
}
func TestConsole_SetLimit_Bad_NegativeBecomesZero(t *testing.T) {
watcher := &ConsoleWatcher{
limit: 10,
handlers: make([]consoleHandlerRegistration, 0),
}
watcher.SetLimit(-1)
if watcher.limit != 0 {
t.Fatalf("SetLimit(-1) = %d, want 0", watcher.limit)
}
}
func TestConsole_NewExceptionWatcher_Good(t *testing.T) {
watcher := NewExceptionWatcher(nil)
if watcher == nil {
t.Fatal("NewExceptionWatcher returned nil")
}
if watcher.Count() != 0 {
t.Fatalf("NewExceptionWatcher count = %d, want 0", watcher.Count())
}
if watcher.limit != 1000 {
t.Fatalf("NewExceptionWatcher limit = %d, want 1000", watcher.limit)
}
}
func TestConsole_NewExceptionWatcher_Good_SubscribesToClient(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
client := newConnectedCDPClient(t, target)
watcher := NewExceptionWatcher(&Webview{client: client})
target.writeJSON(cdpEvent{
Method: "Runtime.exceptionThrown",
Params: map[string]any{
"exceptionDetails": map[string]any{
"text": "boom",
"lineNumber": float64(1),
"columnNumber": float64(2),
"url": "https://example.com/app.js",
},
},
})
time.Sleep(50 * time.Millisecond)
if watcher.Count() != 1 {
t.Fatalf("NewExceptionWatcher subscription count = %d, want 1", watcher.Count())
}
}
func TestConsole_ExceptionWatcherTrimsOldExceptions_Good(t *testing.T) {
watcher := &ExceptionWatcher{
exceptions: make([]ExceptionInfo, 0),
limit: 2,
handlers: make([]exceptionHandlerRegistration, 0),
}
for i := range 3 {
watcher.handleException(map[string]any{
"exceptionDetails": map[string]any{
"text": string(rune('a' + i)),
"lineNumber": float64(i + 1),
"columnNumber": float64(1),
"url": "https://example.com/app.js",
},
})
}
if got := watcher.Count(); got != 2 {
t.Fatalf("ExceptionWatcher count = %d, want 2", got)
}
excs := watcher.Exceptions()
if len(excs) != 2 || excs[0].Text != "b" || excs[1].Text != "c" {
t.Fatalf("ExceptionWatcher retained %#v, want [b c]", excs)
}
}
func TestConsole_ExceptionWatcher_removeHandler_Good(t *testing.T) {
ew := &ExceptionWatcher{
handlers: []exceptionHandlerRegistration{
{id: 1},
{id: 2},
},
}
ew.removeHandler(2)
if len(ew.handlers) != 1 || ew.handlers[0].id != 1 {
t.Fatalf("removeHandler did not remove the requested exception handler: %#v", ew.handlers)
}
ew.removeHandler(99)
if len(ew.handlers) != 1 || ew.handlers[0].id != 1 {
t.Fatalf("removeHandler changed exception handlers unexpectedly: %#v", ew.handlers)
}
}
func TestConsole_WaitForException_Bad_TimesOut(t *testing.T) {
ew := &ExceptionWatcher{
exceptions: make([]ExceptionInfo, 0),
handlers: make([]exceptionHandlerRegistration, 0),
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if _, err := ew.WaitForException(ctx); err == nil {
t.Fatal("WaitForException succeeded without an exception")
}
}
func TestConsole_isWarningType_Good(t *testing.T) {
tests := map[string]bool{
"warn": true,
"warning": true,
"ERROR": false,
}
for raw, want := range tests {
if got := isWarningType(raw); got != want {
t.Fatalf("isWarningType(%q) = %v, want %v", raw, got, want)
}
}
}

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

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

View file

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

View file

@ -9,7 +9,7 @@ description: How to build, test, and contribute to go-webview -- prerequisites,
### Go ### Go
Go 1.26 or later is required. The module path is `forge.lthn.ai/core/go-webview`. Go 1.26 or later is required. The module path is `dappco.re/go/core/webview`.
### Chrome or Chromium ### Chrome or Chromium

View file

@ -2,7 +2,7 @@
## Origin ## Origin
go-webview was extracted from the `pkg/webview/` directory of `forge.lthn.ai/core/go` on 19 February 2026 by Virgil. The extraction made the package independently importable and gave it its own module path, dependency management, and commit history. go-webview was extracted from the `pkg/webview/` directory of `dappco.re/go/core` on 19 February 2026 by Virgil. The extraction made the package independently importable and gave it its own module path, dependency management, and commit history.
--- ---
@ -12,7 +12,7 @@ go-webview was extracted from the `pkg/webview/` directory of `forge.lthn.ai/cor
Commit `45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b` — 2026-02-19 Commit `45f119b9ac0e0ebe34f5c8387a070a5b8bd2de6b` — 2026-02-19
Initial extraction. All source files were moved from `go` `pkg/webview/` into the root of this repository. The module was renamed from the internal path to `forge.lthn.ai/core/go-webview`. Initial extraction. All source files were moved from `go` `pkg/webview/` into the root of this repository. The module was renamed from the internal path to `dappco.re/go/core/webview`.
Files established at extraction: Files established at extraction:
@ -73,7 +73,7 @@ The JavaScript Promise used in `waitForZoneStability` has an internal 5-second `
### CloseTab Implementation ### CloseTab Implementation
`CDPClient.CloseTab` calls `Browser.close`, which closes the entire browser rather than just the tab. The correct CDP command for closing a single tab is `Target.closeTarget` with the target's ID extracted from the WebSocket URL. This is a bug. `CDPClient.CloseTab` now uses `Target.closeTarget` with the current target ID extracted from the WebSocket URL, so closing one tab no longer tears down the entire browser.
--- ---

View file

@ -9,7 +9,7 @@ description: Chrome DevTools Protocol client for browser automation, testing, an
The package does not launch Chrome itself. The caller is responsible for starting the browser process before constructing a `Webview`. The package does not launch Chrome itself. The caller is responsible for starting the browser process before constructing a `Webview`.
**Module path:** `forge.lthn.ai/core/go-webview` **Module path:** `dappco.re/go/core/webview`
**Licence:** EUPL-1.2 **Licence:** EUPL-1.2
**Go version:** 1.26+ **Go version:** 1.26+
**Dependencies:** `github.com/gorilla/websocket v1.5.3` **Dependencies:** `github.com/gorilla/websocket v1.5.3`
@ -33,7 +33,7 @@ google-chrome --headless=new --remote-debugging-port=9222 --no-sandbox --disable
Then use the package in Go: Then use the package in Go:
```go ```go
import "forge.lthn.ai/core/go-webview" import "dappco.re/go/core/webview"
// Connect to Chrome // Connect to Chrome
wv, err := webview.New(webview.WithDebugURL("http://localhost:9222")) wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
@ -136,6 +136,8 @@ value, err := ah.GetComponentProperty("app-widget", "title")
## Further Documentation ## 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 - [Architecture](architecture.md) -- internals, data flow, CDP protocol, type reference
- [Development Guide](development.md) -- build, test, contribute, coding standards - [Development Guide](development.md) -- build, test, contribute, coding standards
- [Project History](history.md) -- extraction origin, completed phases, known limitations - [Project History](history.md) -- extraction origin, completed phases, known limitations
- [Security Attack Vector Mapping](security-attack-vector-mapping.md) -- external input entry points, current validation, and attack-surface notes

View file

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

6
go.mod
View file

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

6
go.sum
View file

@ -1,5 +1,7 @@
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= 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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=

506
specs/RFC.md Normal file
View file

@ -0,0 +1,506 @@
# 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.
New watchers start with an empty exception buffer and a default limit of 1000 stored exceptions. When the limit is exceeded, the oldest entries are trimmed on later writes.
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.getNavigationHistory`, selects the previous entry, and then calls `Page.navigateToHistoryEntry`.
- `GoForward() error`: Calls `Page.getNavigationHistory`, selects the next entry, and then calls `Page.navigateToHistoryEntry`.
- `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 with a 1000-exception limit, and subscribes it to `Runtime.exceptionThrown` events on `wv.client`.
### WithConsoleLimit
`func WithConsoleLimit(limit int) Option`
Returns an `Option` that replaces `Webview.consoleLimit`. The default used by `New` is 1000.
### WithDebugURL
`func WithDebugURL(url string) Option`
Returns an `Option` that dials a `CDPClient` immediately and stores it on the `Webview`.
### WithTimeout
`func WithTimeout(d time.Duration) Option`
Returns an `Option` that replaces the default per-operation timeout used by `Webview` methods.

View file

@ -1,3 +1,4 @@
// SPDX-License-Identifier: EUPL-1.2
// Package webview provides browser automation via Chrome DevTools Protocol (CDP). // Package webview provides browser automation via Chrome DevTools Protocol (CDP).
// //
// The package allows controlling Chrome/Chromium browsers for automated testing, // The package allows controlling Chrome/Chromium browsers for automated testing,
@ -24,14 +25,13 @@ package webview
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt"
"iter" "iter"
"slices" "slices"
"strings"
"sync" "sync"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
) )
// Webview represents a connection to a Chrome DevTools Protocol endpoint. // Webview represents a connection to a Chrome DevTools Protocol endpoint.
@ -76,8 +76,9 @@ type BoundingBox struct {
// Option configures a Webview instance. // Option configures a Webview instance.
type Option func(*Webview) error type Option func(*Webview) error
// WithDebugURL sets the Chrome DevTools debugging URL. // Connect to Chrome running with --remote-debugging-port=9222.
// Example: http://localhost:9222 //
// webview.New(webview.WithDebugURL("http://localhost:9222"))
func WithDebugURL(url string) Option { func WithDebugURL(url string) Option {
return func(wv *Webview) error { return func(wv *Webview) error {
client, err := NewCDPClient(url) client, err := NewCDPClient(url)
@ -89,7 +90,9 @@ func WithDebugURL(url string) Option {
} }
} }
// WithTimeout sets the default timeout for operations. // Give every Webview operation a 10 second default deadline.
//
// webview.New(webview.WithDebugURL("http://localhost:9222"), webview.WithTimeout(10*time.Second))
func WithTimeout(d time.Duration) Option { func WithTimeout(d time.Duration) Option {
return func(wv *Webview) error { return func(wv *Webview) error {
wv.timeout = d wv.timeout = d
@ -97,16 +100,22 @@ func WithTimeout(d time.Duration) Option {
} }
} }
// WithConsoleLimit sets the maximum number of console messages to retain. // Retain only the most recent 200 console messages on the Webview.
// Default is 1000. //
// webview.New(webview.WithDebugURL("http://localhost:9222"), webview.WithConsoleLimit(200))
func WithConsoleLimit(limit int) Option { func WithConsoleLimit(limit int) Option {
return func(wv *Webview) error { return func(wv *Webview) error {
if limit < 0 {
limit = 0
}
wv.consoleLimit = limit wv.consoleLimit = limit
return nil return nil
} }
} }
// New creates a new Webview instance with the given options. // Create a Webview bound to an existing Chrome DevTools endpoint.
//
// wv, err := webview.New(webview.WithDebugURL("http://localhost:9222"))
func New(opts ...Option) (*Webview, error) { func New(opts ...Option) (*Webview, error) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
@ -114,13 +123,20 @@ func New(opts ...Option) (*Webview, error) {
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
timeout: 30 * time.Second, timeout: 30 * time.Second,
consoleLogs: make([]ConsoleMessage, 0, 100), consoleLogs: make([]ConsoleMessage, 0, 1000),
consoleLimit: 1000, consoleLimit: 1000,
} }
cleanupOnError := func() {
cancel()
if wv.client != nil {
_ = wv.client.Close()
}
}
for _, opt := range opts { for _, opt := range opts {
if err := opt(wv); err != nil { if err := opt(wv); err != nil {
cancel() cleanupOnError()
return nil, err return nil, err
} }
} }
@ -132,7 +148,7 @@ func New(opts ...Option) (*Webview, error) {
// Enable console capture // Enable console capture
if err := wv.enableConsole(); err != nil { if err := wv.enableConsole(); err != nil {
cancel() cleanupOnError()
return nil, coreerr.E("Webview.New", "failed to enable console capture", err) return nil, coreerr.E("Webview.New", "failed to enable console capture", err)
} }
@ -148,23 +164,35 @@ func (wv *Webview) Close() error {
return nil return nil
} }
// Navigate navigates to the specified URL. // Load a page and wait for document.readyState === "complete".
//
// wv.Navigate("https://example.com")
func (wv *Webview) Navigate(url string) error { func (wv *Webview) Navigate(url string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
return wv.navigate(ctx, url, "Webview.Navigate")
}
func (wv *Webview) navigate(ctx context.Context, rawURL, scope string) error {
if err := validateNavigationURL(rawURL); err != nil {
return coreerr.E(scope, "invalid navigation URL", err)
}
_, err := wv.client.Call(ctx, "Page.navigate", map[string]any{ _, err := wv.client.Call(ctx, "Page.navigate", map[string]any{
"url": url, "url": rawURL,
}) })
if err != nil { if err != nil {
return coreerr.E("Webview.Navigate", "failed to navigate", err) return coreerr.E(scope, "failed to navigate", err)
} }
// Wait for page load // Wait for page load
return wv.waitForLoad(ctx) return wv.waitForLoad(ctx)
} }
// Click clicks on an element matching the selector. // Click a button or link resolved by CSS selector.
//
// wv.Click("button[type=submit]")
func (wv *Webview) Click(selector string) error { func (wv *Webview) Click(selector string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -172,7 +200,9 @@ func (wv *Webview) Click(selector string) error {
return wv.click(ctx, selector) return wv.click(ctx, selector)
} }
// Type types text into an element matching the selector. // Focus an input and type text through CDP key events.
//
// wv.Type("input[name=email]", "agent@example.com")
func (wv *Webview) Type(selector, text string) error { func (wv *Webview) Type(selector, text string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -180,7 +210,9 @@ func (wv *Webview) Type(selector, text string) error {
return wv.typeText(ctx, selector, text) return wv.typeText(ctx, selector, text)
} }
// QuerySelector finds an element by CSS selector and returns its information. // Inspect the first matching element, including attributes and box metrics.
//
// elem, err := wv.QuerySelector("#main")
func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) { func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -188,7 +220,9 @@ func (wv *Webview) QuerySelector(selector string) (*ElementInfo, error) {
return wv.querySelector(ctx, selector) return wv.querySelector(ctx, selector)
} }
// QuerySelectorAll finds all elements matching the selector. // Inspect every element that matches the CSS selector.
//
// items, err := wv.QuerySelectorAll("table tbody tr")
func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error) { func (wv *Webview) QuerySelectorAll(selector string) ([]*ElementInfo, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -241,7 +275,9 @@ func (wv *Webview) ClearConsole() {
wv.consoleLogs = wv.consoleLogs[:0] wv.consoleLogs = wv.consoleLogs[:0]
} }
// Screenshot captures a screenshot and returns it as PNG bytes. // Capture the current page as PNG bytes.
//
// png, err := wv.Screenshot()
func (wv *Webview) Screenshot() ([]byte, error) { func (wv *Webview) Screenshot() ([]byte, error) {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -266,7 +302,10 @@ func (wv *Webview) Screenshot() ([]byte, error) {
return data, nil return data, nil
} }
// Evaluate executes JavaScript and returns the result. // Run JavaScript in the page and return the serialised value.
//
// title, err := wv.Evaluate("document.title")
//
// Note: This intentionally executes arbitrary JavaScript in the browser context // Note: This intentionally executes arbitrary JavaScript in the browser context
// for browser automation purposes. The script runs in the sandboxed browser environment. // for browser automation purposes. The script runs in the sandboxed browser environment.
func (wv *Webview) Evaluate(script string) (any, error) { func (wv *Webview) Evaluate(script string) (any, error) {
@ -276,7 +315,9 @@ func (wv *Webview) Evaluate(script string) (any, error) {
return wv.evaluate(ctx, script) return wv.evaluate(ctx, script)
} }
// WaitForSelector waits for an element matching the selector to appear. // Block until an element matching the selector exists in the DOM.
//
// wv.WaitForSelector("[data-ready=true]")
func (wv *Webview) WaitForSelector(selector string) error { func (wv *Webview) WaitForSelector(selector string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -329,7 +370,7 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
if selector == "" { if selector == "" {
script = "document.documentElement.outerHTML" script = "document.documentElement.outerHTML"
} else { } else {
script = fmt.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector) script = core.Sprintf("document.querySelector(%q)?.outerHTML || ''", selector)
} }
result, err := wv.evaluate(ctx, script) result, err := wv.evaluate(ctx, script)
@ -345,7 +386,9 @@ func (wv *Webview) GetHTML(selector string) (string, error) {
return html, nil return html, nil
} }
// SetViewport sets the viewport size. // Emulate a 1440x900 desktop viewport for later interactions.
//
// wv.SetViewport(1440, 900)
func (wv *Webview) SetViewport(width, height int) error { func (wv *Webview) SetViewport(width, height int) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -356,10 +399,16 @@ func (wv *Webview) SetViewport(width, height int) error {
"deviceScaleFactor": 1, "deviceScaleFactor": 1,
"mobile": false, "mobile": false,
}) })
if err != nil {
return coreerr.E("Webview.SetViewport", "failed to set viewport", err)
}
return err return err
} }
// SetUserAgent sets the user agent string. // Override the browser user agent for later requests.
//
// wv.SetUserAgent("Mozilla/5.0 AgentHarness/1.0")
func (wv *Webview) SetUserAgent(userAgent string) error { func (wv *Webview) SetUserAgent(userAgent string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
@ -367,6 +416,10 @@ func (wv *Webview) SetUserAgent(userAgent string) error {
_, err := wv.client.Call(ctx, "Emulation.setUserAgentOverride", map[string]any{ _, err := wv.client.Call(ctx, "Emulation.setUserAgentOverride", map[string]any{
"userAgent": userAgent, "userAgent": userAgent,
}) })
if err != nil {
return coreerr.E("Webview.SetUserAgent", "failed to set user agent", err)
}
return err return err
} }
@ -385,24 +438,56 @@ func (wv *Webview) Reload() error {
// GoBack navigates back in history. // GoBack navigates back in history.
func (wv *Webview) GoBack() error { func (wv *Webview) GoBack() error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) return wv.navigateHistory(-1, "Webview.GoBack")
defer cancel()
_, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{
"delta": -1,
})
return err
} }
// GoForward navigates forward in history. // GoForward navigates forward in history.
func (wv *Webview) GoForward() error { func (wv *Webview) GoForward() error {
return wv.navigateHistory(1, "Webview.GoForward")
}
func (wv *Webview) navigateHistory(delta int, scope string) error {
ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout) ctx, cancel := context.WithTimeout(wv.ctx, wv.timeout)
defer cancel() defer cancel()
_, err := wv.client.Call(ctx, "Page.goBackOrForward", map[string]any{ history, err := wv.client.Call(ctx, "Page.getNavigationHistory", nil)
"delta": 1, if err != nil {
return coreerr.E(scope, "failed to get navigation history", err)
}
currentIndexFloat, ok := history["currentIndex"].(float64)
if !ok {
return coreerr.E(scope, "invalid navigation history index", nil)
}
entries, ok := history["entries"].([]any)
if !ok {
return coreerr.E(scope, "invalid navigation history entries", nil)
}
targetIndex := int(currentIndexFloat) + delta
if targetIndex < 0 || targetIndex >= len(entries) {
return coreerr.E(scope, "no navigation history entry available", nil)
}
entry, ok := entries[targetIndex].(map[string]any)
if !ok {
return coreerr.E(scope, "invalid navigation history entry", nil)
}
entryIDFloat, ok := entry["id"].(float64)
if !ok {
return coreerr.E(scope, "invalid navigation history entry id", nil)
}
_, err = wv.client.Call(ctx, "Page.navigateToHistoryEntry", map[string]any{
"entryId": int(entryIDFloat),
}) })
return err if err != nil {
return coreerr.E(scope, "failed to navigate history", err)
}
return wv.waitForLoad(ctx)
} }
// addConsoleMessage adds a console message to the log. // addConsoleMessage adds a console message to the log.
@ -410,11 +495,8 @@ func (wv *Webview) addConsoleMessage(msg ConsoleMessage) {
wv.mu.Lock() wv.mu.Lock()
defer wv.mu.Unlock() defer wv.mu.Unlock()
if len(wv.consoleLogs) >= wv.consoleLimit {
// Remove oldest messages
wv.consoleLogs = wv.consoleLogs[len(wv.consoleLogs)-wv.consoleLimit+100:]
}
wv.consoleLogs = append(wv.consoleLogs, msg) wv.consoleLogs = append(wv.consoleLogs, msg)
wv.consoleLogs = trimConsoleMessages(wv.consoleLogs, wv.consoleLimit)
} }
// enableConsole enables console message capture. // enableConsole enables console message capture.
@ -450,21 +532,11 @@ func (wv *Webview) enableConsole() error {
// handleConsoleEvent processes console API events. // handleConsoleEvent processes console API events.
func (wv *Webview) handleConsoleEvent(params map[string]any) { func (wv *Webview) handleConsoleEvent(params map[string]any) {
msgType, _ := params["type"].(string) msgType := normalizeConsoleType(core.Sprint(params["type"]))
// Extract args // Extract args
args, _ := params["args"].([]any) args, _ := params["args"].([]any)
var text strings.Builder text := consoleTextFromArgs(args)
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))
}
}
}
// Extract stack trace info // Extract stack trace info
stackTrace, _ := params["stackTrace"].(map[string]any) stackTrace, _ := params["stackTrace"].(map[string]any)
@ -482,8 +554,8 @@ func (wv *Webview) handleConsoleEvent(params map[string]any) {
wv.addConsoleMessage(ConsoleMessage{ wv.addConsoleMessage(ConsoleMessage{
Type: msgType, Type: msgType,
Text: text.String(), Text: text,
Timestamp: time.Now(), Timestamp: consoleCaptureTimestamp(),
URL: url, URL: url,
Line: line, Line: line,
Column: column, Column: column,
@ -517,7 +589,7 @@ func (wv *Webview) waitForSelector(ctx context.Context, selector string) error {
ticker := time.NewTicker(100 * time.Millisecond) ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
script := fmt.Sprintf("!!document.querySelector(%q)", selector) script := core.Sprintf("!!document.querySelector(%q)", selector)
for { for {
select { select {
@ -541,6 +613,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{ result, err := wv.client.Call(ctx, "Runtime.evaluate", map[string]any{
"expression": script, "expression": script,
"returnByValue": true, "returnByValue": true,
"awaitPromise": true,
}) })
if err != nil { if err != nil {
return nil, coreerr.E("Webview.evaluate", "failed to evaluate script", err) return nil, coreerr.E("Webview.evaluate", "failed to evaluate script", err)
@ -548,12 +621,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
// Check for exception // Check for exception
if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok { if exceptionDetails, ok := result["exceptionDetails"].(map[string]any); ok {
if exception, ok := exceptionDetails["exception"].(map[string]any); ok { return nil, runtimeExceptionError("Webview.evaluate", exceptionDetails)
if description, ok := exception["description"].(string); ok {
return nil, coreerr.E("Webview.evaluate", description, nil)
}
}
return nil, coreerr.E("Webview.evaluate", "JavaScript error", nil)
} }
// Extract result value // Extract result value
@ -561,7 +629,7 @@ func (wv *Webview) evaluate(ctx context.Context, script string) (any, error) {
return resultObj["value"], nil return resultObj["value"], nil
} }
return nil, nil return nil, coreerr.E("Webview.evaluate", "missing evaluation result", nil)
} }
// querySelector finds an element by selector. // querySelector finds an element by selector.
@ -670,6 +738,8 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
} }
} }
innerHTML, innerText := wv.getElementContent(ctx, nodeID)
// Get bounding box // Get bounding box
var box *BoundingBox var box *BoundingBox
if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{ if boxResult, err := wv.client.Call(ctx, "DOM.getBoxModel", map[string]any{
@ -695,10 +765,61 @@ func (wv *Webview) getElementInfo(ctx context.Context, nodeID int) (*ElementInfo
NodeID: nodeID, NodeID: nodeID,
TagName: tagName, TagName: tagName,
Attributes: attrs, Attributes: attrs,
InnerHTML: innerHTML,
InnerText: innerText,
BoundingBox: box, BoundingBox: box,
}, nil }, nil
} }
// getElementContent retrieves the element's inner HTML and inner text.
func (wv *Webview) getElementContent(ctx context.Context, nodeID int) (string, string) {
resolveResult, err := wv.client.Call(ctx, "DOM.resolveNode", map[string]any{
"nodeId": nodeID,
})
if err != nil {
return "", ""
}
object, ok := resolveResult["object"].(map[string]any)
if !ok {
return "", ""
}
objectID, ok := object["objectId"].(string)
if !ok || objectID == "" {
return "", ""
}
callResult, err := wv.client.Call(ctx, "Runtime.callFunctionOn", map[string]any{
"objectId": objectID,
"functionDeclaration": "function() { return { innerHTML: this.innerHTML || '', innerText: this.innerText || '' }; }",
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return "", ""
}
return parseElementContent(callResult)
}
// parseElementContent extracts inner HTML and inner text from a CDP response.
func parseElementContent(result map[string]any) (string, string) {
resultObj, ok := result["result"].(map[string]any)
if !ok {
return "", ""
}
value, ok := resultObj["value"].(map[string]any)
if !ok {
return "", ""
}
innerHTML, _ := value["innerHTML"].(string)
innerText, _ := value["innerText"].(string)
return innerHTML, innerText
}
// click performs a click on an element. // click performs a click on an element.
func (wv *Webview) click(ctx context.Context, selector string) error { func (wv *Webview) click(ctx context.Context, selector string) error {
// Find element and get its center coordinates // Find element and get its center coordinates
@ -709,7 +830,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
if elem.BoundingBox == nil { if elem.BoundingBox == nil {
// Fallback to JavaScript click // Fallback to JavaScript click
script := fmt.Sprintf("document.querySelector(%q)?.click()", selector) script := core.Sprintf("document.querySelector(%q)?.click()", selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
return err return err
} }
@ -738,7 +859,7 @@ func (wv *Webview) click(ctx context.Context, selector string) error {
// typeText types text into an element. // typeText types text into an element.
func (wv *Webview) typeText(ctx context.Context, selector, text string) error { func (wv *Webview) typeText(ctx context.Context, selector, text string) error {
// Focus the element first // Focus the element first
script := fmt.Sprintf("document.querySelector(%q)?.focus()", selector) script := core.Sprintf("document.querySelector(%q)?.focus()", selector)
_, err := wv.evaluate(ctx, script) _, err := wv.evaluate(ctx, script)
if err != nil { if err != nil {
return coreerr.E("Webview.typeText", "failed to focus element", err) return coreerr.E("Webview.typeText", "failed to focus element", err)

484
webview_methods_test.go Normal file
View file

@ -0,0 +1,484 @@
// SPDX-License-Identifier: EUPL-1.2
package webview
import (
"context"
"encoding/base64"
"strings"
"testing"
"time"
)
func newWebviewHarness(t *testing.T, onMessage func(*fakeCDPTarget, cdpMessage)) (*Webview, *fakeCDPTarget) {
t.Helper()
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = onMessage
client := newConnectedCDPClient(t, target)
wv := &Webview{
client: client,
ctx: context.Background(),
timeout: time.Second,
consoleLogs: make([]ConsoleMessage, 0),
consoleLimit: 10,
}
t.Cleanup(func() {
_ = client.Close()
})
return wv, target
}
func TestWebview_Close_Good(t *testing.T) {
server := newFakeCDPServer(t)
client := newConnectedCDPClient(t, server.primaryTarget())
wv := &Webview{
client: client,
ctx: context.Background(),
cancel: func() {},
consoleLogs: make([]ConsoleMessage, 0),
consoleLimit: 10,
}
if err := wv.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}
func TestWebview_New_Good_EnablesConsoleCapture(t *testing.T) {
server := newFakeCDPServer(t)
target := server.primaryTarget()
target.onMessage = func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "Runtime.enable", "Page.enable", "DOM.enable":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q during New", msg.Method)
}
}
wv, err := New(WithDebugURL(server.DebugURL()))
if err != nil {
t.Fatalf("New returned error: %v", err)
}
defer func() { _ = wv.Close() }()
target.writeJSON(cdpEvent{
Method: "Runtime.consoleAPICalled",
Params: map[string]any{
"type": "log",
"args": []any{map[string]any{"value": "hello"}},
},
})
time.Sleep(50 * time.Millisecond)
if got := wv.GetConsole(); len(got) != 1 || got[0].Text != "hello" {
t.Fatalf("New console capture = %#v", got)
}
}
func TestWebview_Navigate_Bad(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
t.Fatalf("unexpected CDP call %q for invalid navigation URL", msg.Method)
})
if err := wv.Navigate("javascript:alert(1)"); err == nil {
t.Fatal("Navigate succeeded with dangerous URL")
}
}
func TestWebview_Navigate_Good(t *testing.T) {
var methods []string
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
switch msg.Method {
case "Page.navigate":
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
target.replyValue(msg.ID, "complete")
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := wv.Navigate("https://example.com"); err != nil {
t.Fatalf("Navigate returned error: %v", err)
}
if len(methods) != 2 || methods[0] != "Page.navigate" || methods[1] != "Runtime.evaluate" {
t.Fatalf("Navigate call order = %v", methods)
}
}
func TestWebview_QuerySelectorAndAll_Good(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(21)})
case "DOM.querySelectorAll":
target.reply(msg.ID, map[string]any{"nodeIds": []any{float64(21), float64(22)}})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "DIV", "attributes": []any{"id", "main"}}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "<span>hello</span>", "innerText": "hello"}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(1), float64(2), float64(11), float64(2), float64(11), float64(12), float64(1), float64(12)}}})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
elem, err := wv.QuerySelector("#main")
if err != nil {
t.Fatalf("QuerySelector returned error: %v", err)
}
if elem.NodeID != 21 || elem.TagName != "DIV" || elem.InnerText != "hello" {
t.Fatalf("QuerySelector returned %#v", elem)
}
if elem.BoundingBox == nil || elem.BoundingBox.Width != 10 || elem.BoundingBox.Height != 10 {
t.Fatalf("QuerySelector bounding box = %#v", elem.BoundingBox)
}
all, err := wv.QuerySelectorAll("div.item")
if err != nil {
t.Fatalf("QuerySelectorAll returned error: %v", err)
}
if len(all) != 2 {
t.Fatalf("QuerySelectorAll len = %d, want 2", len(all))
}
iterated := make([]int, 0)
for elem := range wv.QuerySelectorAllAll("div.item") {
iterated = append(iterated, elem.NodeID)
break
}
if len(iterated) != 1 || iterated[0] != 21 {
t.Fatalf("QuerySelectorAllAll yielded %v, want first node 21", iterated)
}
}
func TestWebview_ClickAndType_Good(t *testing.T) {
var methods []string
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": []any{float64(10), float64(20), float64(30), float64(20), float64(30), float64(40), float64(10), float64(40)}}})
case "Input.dispatchMouseEvent", "Input.dispatchKeyEvent":
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
target.replyValue(msg.ID, true)
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := wv.Click("#button"); err != nil {
t.Fatalf("Click returned error: %v", err)
}
if err := wv.Type("#input", "ab"); err != nil {
t.Fatalf("Type returned error: %v", err)
}
if len(methods) < 8 {
t.Fatalf("Click+Type methods = %v", methods)
}
}
func TestWebview_WaitForSelector_Good(t *testing.T) {
var calls int
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
calls++
if calls == 1 {
target.replyValue(msg.ID, false)
return
}
target.replyValue(msg.ID, true)
})
if err := wv.WaitForSelector("#ready"); err != nil {
t.Fatalf("WaitForSelector returned error: %v", err)
}
if calls < 2 {
t.Fatalf("WaitForSelector calls = %d, want at least 2", calls)
}
}
func TestWebview_ScreenshotAndInfo_Good(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "Page.captureScreenshot":
if got := msg.Params["format"]; got != "png" {
t.Fatalf("captureScreenshot format = %v, want png", got)
}
target.reply(msg.ID, map[string]any{"data": base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4e, 0x47})})
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
switch expr {
case "window.location.href":
target.replyValue(msg.ID, "https://example.com")
case "document.title":
target.replyValue(msg.ID, "Example")
case "document.documentElement.outerHTML":
target.replyValue(msg.ID, "<html></html>")
case "document.readyState":
target.replyValue(msg.ID, "complete")
default:
t.Fatalf("unexpected evaluate expression %q", expr)
}
case "Emulation.setDeviceMetricsOverride", "Emulation.setUserAgentOverride", "Page.reload":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
png, err := wv.Screenshot()
if err != nil {
t.Fatalf("Screenshot returned error: %v", err)
}
if len(png) != 4 || png[0] != 0x89 {
t.Fatalf("Screenshot bytes = %v", png)
}
if got, err := wv.GetURL(); err != nil || got != "https://example.com" {
t.Fatalf("GetURL = %q, %v", got, err)
}
if got, err := wv.GetTitle(); err != nil || got != "Example" {
t.Fatalf("GetTitle = %q, %v", got, err)
}
if got, err := wv.GetHTML(""); err != nil || got != "<html></html>" {
t.Fatalf("GetHTML = %q, %v", got, err)
}
if err := wv.SetViewport(1440, 900); err != nil {
t.Fatalf("SetViewport returned error: %v", err)
}
if err := wv.SetUserAgent("AgentHarness/1.0"); err != nil {
t.Fatalf("SetUserAgent returned error: %v", err)
}
if err := wv.Reload(); err != nil {
t.Fatalf("Reload returned error: %v", err)
}
}
func TestWebview_Screenshot_Bad_InvalidData(t *testing.T) {
tests := []struct {
name string
result map[string]any
}{
{name: "missing data", result: map[string]any{}},
{name: "invalid base64", result: map[string]any{"data": "%%%"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Page.captureScreenshot" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.reply(msg.ID, tc.result)
})
if _, err := wv.Screenshot(); err == nil {
t.Fatalf("Screenshot succeeded with %#v", tc.result)
}
})
}
}
func TestWebview_GetURL_Bad_InvalidResult(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": float64(1)}})
})
if _, err := wv.GetURL(); err == nil {
t.Fatal("GetURL succeeded with a non-string result")
}
}
func TestWebview_GetTitle_Bad_InvalidResult(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": float64(1)}})
})
if _, err := wv.GetTitle(); err == nil {
t.Fatal("GetTitle succeeded with a non-string result")
}
}
func TestWebview_GetHTML_Bad_InvalidResult(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": float64(1)}})
})
if _, err := wv.GetHTML("#main"); err == nil {
t.Fatal("GetHTML succeeded with a non-string result")
}
}
func TestWebview_NavigateHistory_Bad_MalformedHistory(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Page.getNavigationHistory" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.reply(msg.ID, map[string]any{
"currentIndex": "bad",
"entries": "bad",
})
})
if err := wv.GoBack(); err == nil {
t.Fatal("GoBack succeeded with malformed navigation history")
}
}
func TestWebview_Console_Good(t *testing.T) {
wv := &Webview{
consoleLogs: make([]ConsoleMessage, 0),
consoleLimit: 2,
}
wv.addConsoleMessage(ConsoleMessage{Text: "one"})
wv.addConsoleMessage(ConsoleMessage{Text: "two"})
wv.addConsoleMessage(ConsoleMessage{Text: "three"})
got := wv.GetConsole()
if len(got) != 2 || got[0].Text != "two" || got[1].Text != "three" {
t.Fatalf("GetConsole = %#v", got)
}
iterated := make([]string, 0)
for msg := range wv.GetConsoleAll() {
iterated = append(iterated, msg.Text)
}
if len(iterated) != 2 {
t.Fatalf("GetConsoleAll = %#v", iterated)
}
wv.ClearConsole()
if got := wv.GetConsole(); len(got) != 0 {
t.Fatalf("ClearConsole did not empty logs: %#v", got)
}
}
func TestWebview_UploadFileAndDragAndDrop_Good(t *testing.T) {
var methods []string
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
methods = append(methods, msg.Method)
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
sel, _ := msg.Params["selector"].(string)
switch sel {
case "#file":
target.reply(msg.ID, map[string]any{"nodeId": float64(41)})
case "#source":
target.reply(msg.ID, map[string]any{"nodeId": float64(42)})
case "#target":
target.reply(msg.ID, map[string]any{"nodeId": float64(43)})
default:
t.Fatalf("unexpected selector %q", sel)
}
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "INPUT"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
nodeID := int(msg.Params["nodeId"].(float64))
box := []any{float64(nodeID), float64(nodeID), float64(nodeID + 1), float64(nodeID), float64(nodeID + 1), float64(nodeID + 1), float64(nodeID), float64(nodeID + 1)}
target.reply(msg.ID, map[string]any{"model": map[string]any{"content": box}})
case "DOM.setFileInputFiles", "Input.dispatchMouseEvent":
target.reply(msg.ID, map[string]any{})
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := wv.UploadFile("#file", []string{"/tmp/a.txt"}); err != nil {
t.Fatalf("UploadFile returned error: %v", err)
}
if err := wv.DragAndDrop("#source", "#target"); err != nil {
t.Fatalf("DragAndDrop returned error: %v", err)
}
if len(methods) < 10 {
t.Fatalf("UploadFile+DragAndDrop methods = %v", methods)
}
}
func TestWebview_WaitForSelector_Bad(t *testing.T) {
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
if msg.Method != "Runtime.evaluate" {
t.Fatalf("unexpected method %q", msg.Method)
}
target.replyValue(msg.ID, false)
})
wv.timeout = 50 * time.Millisecond
ctx, cancel := context.WithTimeout(wv.ctx, 50*time.Millisecond)
defer cancel()
if err := wv.waitForSelector(ctx, "#never"); err == nil {
t.Fatal("waitForSelector succeeded without matching element")
}
}
func TestWebview_Click_Ugly_FallsBackToJS(t *testing.T) {
var expressions []string
wv, _ := newWebviewHarness(t, func(target *fakeCDPTarget, msg cdpMessage) {
switch msg.Method {
case "DOM.getDocument":
target.reply(msg.ID, map[string]any{"root": map[string]any{"nodeId": float64(1)}})
case "DOM.querySelector":
target.reply(msg.ID, map[string]any{"nodeId": float64(10)})
case "DOM.describeNode":
target.reply(msg.ID, map[string]any{"node": map[string]any{"nodeName": "BUTTON"}})
case "DOM.resolveNode":
target.reply(msg.ID, map[string]any{"object": map[string]any{"objectId": "obj-1"}})
case "Runtime.callFunctionOn":
target.reply(msg.ID, map[string]any{"result": map[string]any{"value": map[string]any{"innerHTML": "", "innerText": ""}}})
case "DOM.getBoxModel":
target.reply(msg.ID, map[string]any{})
case "Runtime.evaluate":
expr, _ := msg.Params["expression"].(string)
expressions = append(expressions, expr)
target.replyValue(msg.ID, true)
default:
t.Fatalf("unexpected method %q", msg.Method)
}
})
if err := wv.Click("#button"); err != nil {
t.Fatalf("Click returned error: %v", err)
}
if len(expressions) != 1 || !strings.Contains(expressions[0], `document.querySelector("#button")?.click()`) {
t.Fatalf("Click fallback expression = %v", expressions)
}
}

View file

@ -1,6 +1,12 @@
// SPDX-License-Identifier: EUPL-1.2
package webview package webview
import ( import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
) )
@ -118,6 +124,20 @@ func TestWithConsoleLimit_Good(t *testing.T) {
} }
} }
// TestWithConsoleLimit_Bad_NegativeBecomesZero verifies negative limits are clamped to zero.
func TestWithConsoleLimit_Bad_NegativeBecomesZero(t *testing.T) {
wv := &Webview{consoleLimit: 10}
opt := WithConsoleLimit(-1)
if err := opt(wv); err != nil {
t.Fatalf("WithConsoleLimit returned error: %v", err)
}
if wv.consoleLimit != 0 {
t.Fatalf("Expected consoleLimit 0, got %d", wv.consoleLimit)
}
}
// TestNew_Bad_NoDebugURL verifies New fails without a debug URL. // TestNew_Bad_NoDebugURL verifies New fails without a debug URL.
func TestNew_Bad_NoDebugURL(t *testing.T) { func TestNew_Bad_NoDebugURL(t *testing.T) {
_, err := New() _, err := New()
@ -134,6 +154,13 @@ func TestNew_Bad_InvalidDebugURL(t *testing.T) {
} }
} }
func TestWebview_Close_Good_NoClient(t *testing.T) {
wv := &Webview{cancel: func() {}}
if err := wv.Close(); err != nil {
t.Fatalf("Close returned error for nil client: %v", err)
}
}
// TestActionSequence_Good verifies action sequence building works. // TestActionSequence_Good verifies action sequence building works.
func TestActionSequence_Good(t *testing.T) { func TestActionSequence_Good(t *testing.T) {
seq := NewActionSequence(). seq := NewActionSequence().
@ -148,6 +175,52 @@ func TestActionSequence_Good(t *testing.T) {
} }
} }
// TestActionSequence_Good_AllBuilders verifies every fluent builder appends the expected action.
func TestActionSequence_Good_AllBuilders(t *testing.T) {
seq := NewActionSequence().
Scroll(0, 500).
ScrollIntoView("#target").
Focus("#input").
Blur("#input").
Clear("#input").
Select("#dropdown", "option1").
Check("#checkbox", true).
Hover("#menu-item").
DoubleClick("#editable").
RightClick("#context-menu-trigger").
PressKey("Enter").
SetAttribute("#element", "data-value", "test").
RemoveAttribute("#element", "disabled").
SetValue("#input", "new value")
if len(seq.actions) != 14 {
t.Fatalf("Expected 14 actions, got %d", len(seq.actions))
}
wantTypes := []any{
ScrollAction{X: 0, Y: 500},
ScrollIntoViewAction{Selector: "#target"},
FocusAction{Selector: "#input"},
BlurAction{Selector: "#input"},
ClearAction{Selector: "#input"},
SelectAction{Selector: "#dropdown", Value: "option1"},
CheckAction{Selector: "#checkbox", Checked: true},
HoverAction{Selector: "#menu-item"},
DoubleClickAction{Selector: "#editable"},
RightClickAction{Selector: "#context-menu-trigger"},
PressKeyAction{Key: "Enter"},
SetAttributeAction{Selector: "#element", Attribute: "data-value", Value: "test"},
RemoveAttributeAction{Selector: "#element", Attribute: "disabled"},
SetValueAction{Selector: "#input", Value: "new value"},
}
for i, want := range wantTypes {
if got := seq.actions[i]; got != want {
t.Fatalf("action %d = %#v, want %#v", i, got, want)
}
}
}
// TestClickAction_Good verifies ClickAction struct. // TestClickAction_Good verifies ClickAction struct.
func TestClickAction_Good(t *testing.T) { func TestClickAction_Good(t *testing.T) {
action := ClickAction{Selector: "#submit"} action := ClickAction{Selector: "#submit"}
@ -333,3 +406,669 @@ func TestScrollIntoViewAction_Good(t *testing.T) {
t.Errorf("Expected selector '#target', got %q", action.Selector) t.Errorf("Expected selector '#target', got %q", action.Selector)
} }
} }
// TestFormatConsoleOutput_Good verifies console output formatting.
func TestFormatConsoleOutput_Good(t *testing.T) {
ts := time.Date(2026, 1, 15, 14, 30, 45, 123000000, time.UTC)
messages := []ConsoleMessage{
{Type: "error", Text: "something broke", Timestamp: ts},
{Type: "warning", Text: "deprecated call", Timestamp: ts},
{Type: "info", Text: "loaded", Timestamp: ts},
{Type: "debug", Text: "trace data", Timestamp: ts},
{Type: "log", Text: "hello world", Timestamp: ts},
}
output := FormatConsoleOutput(messages)
expected := []string{
"14:30:45.123 [ERROR] something broke",
"14:30:45.123 [WARN] deprecated call",
"14:30:45.123 [INFO] loaded",
"14:30:45.123 [DEBUG] trace data",
"14:30:45.123 [LOG] hello world",
}
for _, exp := range expected {
if !containsString(output, exp) {
t.Errorf("Expected output to contain %q", exp)
}
}
}
// TestFormatConsoleOutput_Good_Empty verifies empty message list.
func TestFormatConsoleOutput_Good_Empty(t *testing.T) {
output := FormatConsoleOutput(nil)
if output != "" {
t.Errorf("Expected empty string, got %q", output)
}
}
// TestFormatConsoleOutput_Good_SanitisesControlCharacters verifies console output is safe for log sinks.
func TestFormatConsoleOutput_Good_SanitisesControlCharacters(t *testing.T) {
output := FormatConsoleOutput([]ConsoleMessage{
{
Type: "error",
Text: "first line\nsecond line\x1b[31m",
Timestamp: time.Date(2026, 1, 15, 14, 30, 45, 0, time.UTC),
},
})
if !containsString(output, `first line\nsecond line\x1b[31m`) {
t.Fatalf("expected control characters to be escaped, got %q", output)
}
if containsString(output, "\nsecond line") {
t.Fatalf("expected embedded newlines to be escaped, got %q", output)
}
}
// TestNormalizeConsoleType_Good verifies CDP warning aliases are normalised.
func TestNormalizeConsoleType_Good(t *testing.T) {
if got := normalizeConsoleType("warn"); got != "warn" {
t.Fatalf("normalizeConsoleType(\"warn\") = %q, want %q", got, "warn")
}
if got := normalizeConsoleType("WARNING"); got != "warn" {
t.Fatalf("normalizeConsoleType(\"WARNING\") = %q, want %q", got, "warn")
}
}
// TestWebviewHandleConsoleEvent_Good_NormalizesWarningType verifies CDP warning aliases are stored as warn.
func TestWebviewHandleConsoleEvent_Good_NormalizesWarningType(t *testing.T) {
wv := &Webview{
consoleLogs: make([]ConsoleMessage, 0),
consoleLimit: 10,
}
wv.handleConsoleEvent(map[string]any{
"type": "warn",
"args": []any{
map[string]any{"value": "deprecated"},
},
})
if len(wv.consoleLogs) != 1 {
t.Fatalf("Expected one console message, got %d", len(wv.consoleLogs))
}
if wv.consoleLogs[0].Type != "warn" {
t.Fatalf("Expected warn type, got %q", wv.consoleLogs[0].Type)
}
if wv.consoleLogs[0].Text != "deprecated" {
t.Fatalf("Expected text %q, got %q", "deprecated", wv.consoleLogs[0].Text)
}
}
// TestContainsString_Good verifies substring matching.
func TestContainsString_Good(t *testing.T) {
tests := []struct {
s, substr string
want bool
}{
{"hello world", "world", true},
{"hello world", "hello", true},
{"hello world", "xyz", false},
{"hello", "", true},
{"", "", true},
{"", "a", false},
{"abc", "abc", true},
{"abc", "abcd", false},
}
for _, tc := range tests {
got := containsString(tc.s, tc.substr)
if got != tc.want {
t.Errorf("containsString(%q, %q) = %v, want %v", tc.s, tc.substr, got, tc.want)
}
}
}
// TestFindString_Good verifies string search.
func TestFindString_Good(t *testing.T) {
tests := []struct {
s, substr string
want int
}{
{"hello world", "world", 6},
{"hello world", "hello", 0},
{"hello world", "xyz", -1},
{"abcabc", "abc", 0},
{"abc", "abc", 0},
}
for _, tc := range tests {
got := findString(tc.s, tc.substr)
if got != tc.want {
t.Errorf("findString(%q, %q) = %d, want %d", tc.s, tc.substr, got, tc.want)
}
}
}
// TestFormatJSValue_Good verifies JavaScript value formatting.
func TestFormatJSValue_Good(t *testing.T) {
tests := []struct {
input any
want string
}{
{"hello", `"hello"`},
{true, "true"},
{false, "false"},
{nil, "null"},
{42, "42"},
{3.14, "3.14"},
{map[string]any{"enabled": true}, `{"enabled":true}`},
{[]any{1, "two"}, `[1,"two"]`},
}
for _, tc := range tests {
got := formatJSValue(tc.input)
if got != tc.want {
t.Errorf("formatJSValue(%v) = %q, want %q", tc.input, got, tc.want)
}
}
}
// TestParseDebugURL_Bad_RejectsRemoteHosts verifies debug endpoints are loopback-only.
func TestParseDebugURL_Bad_RejectsRemoteHosts(t *testing.T) {
for _, raw := range []string{
"http://example.com:9222",
"http://10.0.0.1:9222",
"http://[2001:db8::1]:9222",
} {
if _, err := parseDebugURL(raw); err == nil {
t.Fatalf("parseDebugURL(%q) returned nil error", raw)
}
}
}
// TestParseDebugURL_Good_AllowsLoopbackHosts verifies local debugging endpoints remain usable.
func TestParseDebugURL_Good_AllowsLoopbackHosts(t *testing.T) {
for _, raw := range []string{
"http://localhost:9222",
"http://127.0.0.1:9222",
"http://[::1]:9222",
} {
if _, err := parseDebugURL(raw); err != nil {
t.Fatalf("parseDebugURL(%q) returned error: %v", raw, err)
}
}
}
// TestValidateNavigationURL_Good_AllowsWebURLs verifies navigation accepts HTTP(S) pages.
func TestValidateNavigationURL_Good_AllowsWebURLs(t *testing.T) {
for _, raw := range []string{
"https://example.com",
"http://localhost:8080/path?q=1",
"about:blank",
} {
if err := validateNavigationURL(raw); err != nil {
t.Fatalf("validateNavigationURL(%q) returned error: %v", raw, err)
}
}
}
// TestValidateNavigationURL_Bad_RejectsDangerousSchemes verifies non-web schemes are blocked.
func TestValidateNavigationURL_Bad_RejectsDangerousSchemes(t *testing.T) {
for _, raw := range []string{
"javascript:alert(1)",
"data:text/html,hello",
"file:///etc/passwd",
"about:srcdoc",
"ftp://example.com",
} {
if err := validateNavigationURL(raw); err == nil {
t.Fatalf("validateNavigationURL(%q) returned nil error", raw)
}
}
}
// TestDoDebugRequest_Bad_RejectsOversizedBody verifies debug responses are bounded.
func TestDoDebugRequest_Bad_RejectsOversizedBody(t *testing.T) {
var payload strings.Builder
payload.Grow(maxDebugResponseBytes + 1)
payload.WriteString(strings.Repeat("a", maxDebugResponseBytes+1))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, payload.String())
}))
t.Cleanup(server.Close)
debugURL, err := parseDebugURL(server.URL)
if err != nil {
t.Fatalf("parseDebugURL returned error: %v", err)
}
if _, err := doDebugRequest(context.Background(), debugURL, "/json", ""); err == nil {
t.Fatal("doDebugRequest returned nil error for oversized body")
}
}
// TestGetString_Good verifies map string extraction.
func TestGetString_Good(t *testing.T) {
m := map[string]any{
"name": "test",
"count": 42,
}
if got := getString(m, "name"); got != "test" {
t.Errorf("getString(m, 'name') = %q, want 'test'", got)
}
if got := getString(m, "count"); got != "" {
t.Errorf("getString(m, 'count') = %q, want empty (not a string)", got)
}
if got := getString(m, "missing"); got != "" {
t.Errorf("getString(m, 'missing') = %q, want empty", got)
}
}
// TestParseElementContent_Good verifies inner content extraction from CDP output.
func TestParseElementContent_Good(t *testing.T) {
result := map[string]any{
"result": map[string]any{
"value": map[string]any{
"innerHTML": "<span>Hello</span>",
"innerText": "Hello",
},
},
}
innerHTML, innerText := parseElementContent(result)
if innerHTML != "<span>Hello</span>" {
t.Fatalf("parseElementContent innerHTML = %q, want %q", innerHTML, "<span>Hello</span>")
}
if innerText != "Hello" {
t.Fatalf("parseElementContent innerText = %q, want %q", innerText, "Hello")
}
}
// TestWaitAction_Good_ContextCancelled verifies WaitAction respects context cancellation.
func TestWaitAction_Good_ContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
action := WaitAction{Duration: 10 * time.Second}
err := action.Execute(ctx, nil)
if err == nil {
t.Error("Expected context cancelled error")
}
}
// TestWaitAction_Good_ShortWait verifies WaitAction completes after duration.
func TestWaitAction_Good_ShortWait(t *testing.T) {
ctx := context.Background()
action := WaitAction{Duration: 10 * time.Millisecond}
start := time.Now()
err := action.Execute(ctx, nil)
elapsed := time.Since(start)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if elapsed < 10*time.Millisecond {
t.Errorf("Expected at least 10ms elapsed, got %v", elapsed)
}
}
// TestAddConsoleMessage_Good verifies console message buffer management.
func TestAddConsoleMessage_Good(t *testing.T) {
wv := &Webview{
consoleLogs: make([]ConsoleMessage, 0, 10),
consoleLimit: 5,
}
// Add messages up to the limit
for i := range 6 {
wv.addConsoleMessage(ConsoleMessage{
Type: "log",
Text: time.Duration(i).String(),
})
}
// Buffer should have been trimmed
if len(wv.consoleLogs) > wv.consoleLimit {
t.Errorf("Expected at most %d messages, got %d", wv.consoleLimit, len(wv.consoleLogs))
}
}
// TestAddConsoleMessage_Good_ZeroLimitDropsMessages verifies zero retention disables storage.
func TestAddConsoleMessage_Good_ZeroLimitDropsMessages(t *testing.T) {
wv := &Webview{
consoleLogs: make([]ConsoleMessage, 0, 1),
consoleLimit: 0,
}
wv.addConsoleMessage(ConsoleMessage{Type: "log", Text: "ignored"})
if len(wv.consoleLogs) != 0 {
t.Fatalf("Expected zero retained messages, got %d", len(wv.consoleLogs))
}
}
// TestConsoleWatcherFilter_Good verifies console watcher filter matching.
func TestConsoleWatcherFilter_Good(t *testing.T) {
// Create a minimal ConsoleWatcher without a real Webview
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
// No filters — everything matches
msg := ConsoleMessage{Type: "error", Text: "test error"}
if !cw.matchesFilter(msg) {
t.Error("Expected message to match with no filters")
}
// Add type filter
cw.AddFilter(ConsoleFilter{Type: "error"})
if !cw.matchesFilter(msg) {
t.Error("Expected error message to match error filter")
}
logMsg := ConsoleMessage{Type: "log", Text: "test log"}
if cw.matchesFilter(logMsg) {
t.Error("Expected log message NOT to match error filter")
}
// Add pattern filter
cw.ClearFilters()
cw.AddFilter(ConsoleFilter{Pattern: "hello"})
helloMsg := ConsoleMessage{Type: "log", Text: "hello world"}
if !cw.matchesFilter(helloMsg) {
t.Error("Expected 'hello world' to match pattern 'hello'")
}
if cw.matchesFilter(msg) {
t.Error("Expected 'test error' NOT to match pattern 'hello'")
}
cw.ClearFilters()
cw.AddFilter(ConsoleFilter{Type: "warning"})
if !cw.matchesFilter(ConsoleMessage{Type: "warning", Text: "deprecated"}) {
t.Error("Expected warning message to match warning filter")
}
if !cw.matchesFilter(ConsoleMessage{Type: "warn", Text: "deprecated"}) {
t.Error("Expected warn message to match warning filter")
}
cw.ClearFilters()
cw.AddFilter(ConsoleFilter{Type: "warn"})
if !cw.matchesFilter(ConsoleMessage{Type: "warning", Text: "deprecated"}) {
t.Error("Expected warning message to match warn filter")
}
}
// TestConsoleWatcherCounts_Good verifies console watcher counting methods.
func TestConsoleWatcherCounts_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: []ConsoleMessage{
{Type: "log", Text: "info 1"},
{Type: "error", Text: "err 1"},
{Type: "log", Text: "info 2"},
{Type: "error", Text: "err 2"},
{Type: "warn", Text: "warn 1"},
},
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
if cw.Count() != 5 {
t.Errorf("Expected count 5, got %d", cw.Count())
}
if cw.ErrorCount() != 2 {
t.Errorf("Expected error count 2, got %d", cw.ErrorCount())
}
if !cw.HasErrors() {
t.Error("Expected HasErrors() to be true")
}
errors := cw.Errors()
if len(errors) != 2 {
t.Errorf("Expected 2 errors, got %d", len(errors))
}
warnings := cw.Warnings()
if len(warnings) != 1 {
t.Errorf("Expected 1 warning, got %d", len(warnings))
}
cw.Clear()
if cw.Count() != 0 {
t.Errorf("Expected count 0 after clear, got %d", cw.Count())
}
if cw.HasErrors() {
t.Error("Expected HasErrors() to be false after clear")
}
}
// TestExceptionWatcher_Good verifies exception watcher basic operations.
func TestExceptionWatcher_Good(t *testing.T) {
ew := &ExceptionWatcher{
exceptions: make([]ExceptionInfo, 0),
handlers: make([]exceptionHandlerRegistration, 0),
}
if ew.HasExceptions() {
t.Error("Expected no exceptions initially")
}
if ew.Count() != 0 {
t.Errorf("Expected count 0, got %d", ew.Count())
}
// Simulate adding an exception
ew.exceptions = append(ew.exceptions, ExceptionInfo{
Text: "TypeError: undefined is not a function",
LineNumber: 10,
URL: "https://example.com/app.js",
})
if !ew.HasExceptions() {
t.Error("Expected HasExceptions() to be true")
}
if ew.Count() != 1 {
t.Errorf("Expected count 1, got %d", ew.Count())
}
exceptions := ew.Exceptions()
if len(exceptions) != 1 {
t.Errorf("Expected 1 exception, got %d", len(exceptions))
}
if exceptions[0].Text != "TypeError: undefined is not a function" {
t.Errorf("Unexpected exception text: %q", exceptions[0].Text)
}
ew.Clear()
if ew.Count() != 0 {
t.Errorf("Expected count 0 after clear, got %d", ew.Count())
}
}
// TestAngularRouterState_Good verifies AngularRouterState struct.
func TestAngularRouterState_Good(t *testing.T) {
state := AngularRouterState{
URL: "/dashboard",
Fragment: "section1",
Params: map[string]string{"id": "123"},
QueryParams: map[string]string{
"tab": "settings",
},
}
if state.URL != "/dashboard" {
t.Errorf("Expected URL '/dashboard', got %q", state.URL)
}
if state.Fragment != "section1" {
t.Errorf("Expected fragment 'section1', got %q", state.Fragment)
}
if state.Params["id"] != "123" {
t.Errorf("Expected param id '123', got %q", state.Params["id"])
}
if state.QueryParams["tab"] != "settings" {
t.Errorf("Expected query param tab 'settings', got %q", state.QueryParams["tab"])
}
}
// TestTargetInfo_Good verifies TargetInfo struct.
func TestTargetInfo_Good(t *testing.T) {
target := TargetInfo{
ID: "ABC123",
Type: "page",
Title: "Example",
URL: "https://example.com",
WebSocketDebuggerURL: "ws://localhost:9222/devtools/page/ABC123",
}
if target.ID != "ABC123" {
t.Errorf("Expected ID 'ABC123', got %q", target.ID)
}
if target.Type != "page" {
t.Errorf("Expected type 'page', got %q", target.Type)
}
if target.WebSocketDebuggerURL == "" {
t.Error("Expected WebSocketDebuggerURL to be set")
}
}
// TestConsoleWatcherAddMessage_Good verifies message buffer limit enforcement.
func TestConsoleWatcherAddMessage_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 5,
handlers: make([]consoleHandlerRegistration, 0),
}
// Add messages past the limit
for i := range 7 {
cw.addMessage(ConsoleMessage{
Type: "log",
Text: time.Duration(i).String(),
})
}
if len(cw.messages) > cw.limit {
t.Errorf("Expected at most %d messages, got %d", cw.limit, len(cw.messages))
}
}
// TestConsoleWatcherHandler_Good verifies handlers are called for new messages.
func TestConsoleWatcherHandler_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: make([]ConsoleMessage, 0),
filters: make([]ConsoleFilter, 0),
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
var received ConsoleMessage
cw.AddHandler(func(msg ConsoleMessage) {
received = msg
})
cw.addMessage(ConsoleMessage{Type: "error", Text: "handler test"})
if received.Text != "handler test" {
t.Errorf("Handler not called or wrong message: got %q", received.Text)
}
}
// TestConsoleWatcherFilteredMessages_Good verifies filtered message retrieval.
func TestConsoleWatcherFilteredMessages_Good(t *testing.T) {
cw := &ConsoleWatcher{
messages: []ConsoleMessage{
{Type: "log", Text: "info msg"},
{Type: "error", Text: "error msg"},
{Type: "log", Text: "another info"},
},
filters: []ConsoleFilter{{Type: "error"}},
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
filtered := cw.FilteredMessages()
if len(filtered) != 1 {
t.Fatalf("Expected 1 filtered message, got %d", len(filtered))
}
if filtered[0].Type != "error" {
t.Errorf("Expected error type, got %q", filtered[0].Type)
}
}
// TestConsoleWatcherFilteredMessages_Good_UsesAnyActiveFilter verifies filters compose as a union.
func TestConsoleWatcherFilteredMessages_Good_UsesAnyActiveFilter(t *testing.T) {
cw := &ConsoleWatcher{
messages: []ConsoleMessage{
{Type: "error", Text: "boom happened"},
{Type: "error", Text: "different message"},
{Type: "log", Text: "boom happened"},
},
filters: []ConsoleFilter{
{Type: "error"},
{Pattern: "boom"},
},
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
filtered := cw.FilteredMessages()
if len(filtered) != 3 {
t.Fatalf("Expected 3 filtered messages, got %d", len(filtered))
}
if filtered[0].Text != "boom happened" {
t.Fatalf("Expected the first matching message, got %q", filtered[0].Text)
}
if filtered[1].Text != "different message" {
t.Fatalf("Expected the second stored message to remain visible, got %q", filtered[1].Text)
}
if filtered[2].Text != "boom happened" {
t.Fatalf("Expected the log message matching the pattern filter, got %q", filtered[2].Text)
}
}
// TestConsoleWatcherSetLimit_Good_AppliesToFutureWrites verifies shrinking the limit trims buffered messages on the next append.
func TestConsoleWatcherSetLimit_Good_AppliesToFutureWrites(t *testing.T) {
cw := &ConsoleWatcher{
messages: []ConsoleMessage{
{Type: "log", Text: "first"},
{Type: "log", Text: "second"},
{Type: "log", Text: "third"},
},
limit: 1000,
handlers: make([]consoleHandlerRegistration, 0),
}
cw.SetLimit(2)
if cw.Count() != 3 {
t.Fatalf("Expected 3 messages to remain until the next append, got %d", cw.Count())
}
cw.addMessage(ConsoleMessage{Type: "log", Text: "fourth"})
if cw.Count() != 2 {
t.Fatalf("Expected 2 messages after the next append, got %d", cw.Count())
}
if messages := cw.Messages(); messages[0].Text != "third" || messages[1].Text != "fourth" {
t.Fatalf("Unexpected retained messages after trimming: %#v", messages)
}
}
// TestExceptionInfo_Good verifies ExceptionInfo struct.
func TestExceptionInfo_Good(t *testing.T) {
info := ExceptionInfo{
Text: "ReferenceError: foo is not defined",
LineNumber: 42,
ColumnNumber: 10,
URL: "https://example.com/app.js",
StackTrace: " at bar (app.js:42:10)\n",
Timestamp: time.Now(),
}
if info.Text != "ReferenceError: foo is not defined" {
t.Errorf("Unexpected text: %q", info.Text)
}
if info.LineNumber != 42 {
t.Errorf("Expected line 42, got %d", info.LineNumber)
}
if info.StackTrace == "" {
t.Error("Expected stack trace to be set")
}
}