diff --git a/interactive.go b/interactive.go
index 896e85b..d776453 100644
--- a/interactive.go
+++ b/interactive.go
@@ -45,6 +45,14 @@ type LiveDocument struct {
vpW, vpH int
stopHeap func()
closed bool
+ // focused is the node synthetic.go's Focus most recently moved input
+ // focus to (nil if none), and focusedValue is that node's value at the
+ // moment it was focused — the baseline Blur compares against to decide
+ // whether a real "change" event is due, matching a browser firing
+ // change only when a focused control's value actually differs at blur,
+ // not on every keystroke (that is what "input" is for).
+ focused *dom.Node
+ focusedValue string
// prevLinks is the stylesheet set digest AS OF THE LAST SETTLED
// FRAME — carried across Interact calls (unlike settle's own prevLinks,
// which is scoped to one render). resettle compares against this to
diff --git a/js/dispatch_test.go b/js/dispatch_test.go
new file mode 100644
index 0000000..ef04834
--- /dev/null
+++ b/js/dispatch_test.go
@@ -0,0 +1,94 @@
+// Copyright (c) the go-webengine/engine authors.
+// SPDX-License-Identifier: BSD-3-Clause
+
+package js
+
+import (
+ "testing"
+ "time"
+
+ "github.com/go-webengine/engine/dom"
+)
+
+// TestSessionDispatchReachesJSListener is the load-bearing proof for the
+// Go-facing seam: a listener a page's OWN script registered must fire when
+// something OUTSIDE this package (a host synthesizing a real user
+// interaction) calls Session.Dispatch, exactly as if the page had called
+// element.dispatchEvent() itself.
+func TestSessionDispatchReachesJSListener(t *testing.T) {
+ const src = `
+
+
+ `
+ root, err := dom.Parse(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ field := dom.Find(root, "input")
+
+ var logs []string
+ sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second,
+ Log: func(l string) { logs = append(logs, l) }})
+ defer sess.Close()
+ sess.RunInitial()
+
+ sess.Dispatch(field, "keydown", EventInit{Bubbles: true, Cancelable: true, Key: "a"})
+ sess.Dispatch(field, "input", EventInit{Bubbles: true, Data: "a", InputType: "insertText"})
+ // Delegated: the listener is on #parent, the event targets #field.
+ sess.Dispatch(field, "click", EventInit{Bubbles: true, Cancelable: true})
+
+ mustHaveJS(t, logs, "keydown key=a", "input data=a type=insertText", "delegated click target=field")
+}
+
+// TestSessionDispatchReportsPreventDefault is what a native-form-submission
+// fallback (a later phase) needs: knowing whether a script intercepted the
+// event, without the caller having to inspect the event object itself.
+func TestSessionDispatchReportsPreventDefault(t *testing.T) {
+ const src = `
+
+ `
+ root, err := dom.Parse(src)
+ if err != nil {
+ t.Fatal(err)
+ }
+ form := dom.Find(root, "form")
+
+ sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second})
+ defer sess.Close()
+ sess.RunInitial()
+
+ if prevented := sess.Dispatch(form, "submit", EventInit{Bubbles: true, Cancelable: true}); !prevented {
+ t.Fatal("Dispatch: want defaultPrevented=true, the listener called preventDefault()")
+ }
+}
+
+// TestSessionDispatchNoListenersIsSafe covers dispatching to a node with no
+// registered listeners at all (a plain nobody wired up) — must not
+// panic, and must report defaultPrevented=false.
+func TestSessionDispatchNoListenersIsSafe(t *testing.T) {
+ root, err := dom.Parse(`Go `)
+ if err != nil {
+ t.Fatal(err)
+ }
+ btn := dom.Find(root, "button")
+
+ sess := Begin(root, Options{PageURL: "https://demo.test/", Timeout: 3 * time.Second})
+ defer sess.Close()
+ sess.RunInitial()
+
+ if prevented := sess.Dispatch(btn, "click", EventInit{Bubbles: true}); prevented {
+ t.Fatal("Dispatch with no listeners: want defaultPrevented=false")
+ }
+}
diff --git a/js/events.go b/js/events.go
index 485c802..fbb6311 100644
--- a/js/events.go
+++ b/js/events.go
@@ -43,22 +43,60 @@ func (b *binder) removeListener(n *dom.Node, typ string, handler goja.Value) {
}
}
-// dispatch fires every listener registered for typ on n, invoking each with the
-// event as argument and n as `this`. Handler errors/panics are contained.
+// dispatch fires every listener registered for typ on n (the target phase),
+// then — when event.bubbles is true and no handler called stopPropagation/
+// stopImmediatePropagation — continues up n's ancestor chain doing the same,
+// matching real DOM event bubbling (needed for delegation: a handler
+// registered on a container rather than the specific button/input a
+// synthetic click/keystroke targets). event.target and .currentTarget are
+// kept live across the walk. Handler errors/panics are contained per node,
+// same as before this walked more than one node.
+//
+// Scope: does not continue past the document root to a window-level
+// listener (window has no place in n's Parent chain) — document-level
+// delegation, the common real-world case, works; window-level does not yet.
func (b *binder) dispatch(n *dom.Node, typ string, event goja.Value) {
- m := b.listeners[n]
- if m == nil {
- return
- }
- self := b.wrap(n)
- if n == b.windowNode || n == b.docNode {
- self = b.vm.GlobalObject()
+ obj, _ := event.(*goja.Object)
+ // stopped halts moving to an ancestor; stoppedImmediate ALSO halts the
+ // remaining listeners on the CURRENT node — the real distinction between
+ // stopPropagation (later listeners on this same node still run) and
+ // stopImmediatePropagation (they don't) that a single flag would blur.
+ stopped, stoppedImmediate := false, false
+ bubbles := false
+ if obj != nil {
+ obj.Set("target", b.wrap(n))
+ obj.Set("stopPropagation", func(goja.FunctionCall) goja.Value { stopped = true; return goja.Undefined() })
+ obj.Set("stopImmediatePropagation", func(goja.FunctionCall) goja.Value {
+ stopped, stoppedImmediate = true, true
+ return goja.Undefined()
+ })
+ if bv := obj.Get("bubbles"); bv != nil {
+ bubbles = bv.ToBoolean()
+ }
}
- // Copy so a handler that mutates the list mid-dispatch is safe.
- hs := append([]goja.Value(nil), m[typ]...)
- for _, h := range hs {
- fn := b.handlerFunc(h)
- b.callSafely(fn, self, event)
+
+ for cur := n; cur != nil; cur = cur.Parent {
+ if m := b.listeners[cur]; m != nil {
+ self := b.wrap(cur)
+ if cur == b.windowNode || cur == b.docNode {
+ self = b.vm.GlobalObject()
+ }
+ if obj != nil {
+ obj.Set("currentTarget", self)
+ }
+ // Copy so a handler that mutates the list mid-dispatch is safe.
+ hs := append([]goja.Value(nil), m[typ]...)
+ for _, h := range hs {
+ fn := b.handlerFunc(h)
+ b.callSafely(fn, self, event)
+ if stoppedImmediate {
+ break
+ }
+ }
+ }
+ if stopped || !bubbles {
+ break
+ }
}
}
diff --git a/js/js_test.go b/js/js_test.go
index ee8b73e..807b50f 100644
--- a/js/js_test.go
+++ b/js/js_test.go
@@ -586,6 +586,88 @@ func TestEvents(t *testing.T) {
}
}
+func TestEventBubbling(t *testing.T) {
+ // Three independent subtrees (not one reused tree) so each scenario's
+ // listeners can't accumulate onto another's nodes.
+ const src = `
+
+
+
+
+ `
+ _, logs, _ := runJS(t, src)
+ mustHave(t, logs,
+ "bubbled=child,parent,grandparent",
+ // child's own (target-phase) listener still fires, parent's fires
+ // and calls stopPropagation — grandparent's must NOT.
+ "stopped=child,parent",
+ // bubbles:false must not even reach parent.
+ "nonbubbling=child")
+}
+
+// TestStopPropagationVsStopImmediate covers the real distinction: plain
+// stopPropagation still lets LATER listeners on the SAME node run (only
+// ancestors are skipped); stopImmediatePropagation does not.
+func TestStopPropagationVsStopImmediate(t *testing.T) {
+ const src = `
+
+
+
+ `
+ _, logs, _ := runJS(t, src)
+ mustHave(t, logs,
+ "stopProp=first,second", // second same-node listener still ran; parent did not
+ "stopImmediate=first") // second same-node listener did NOT run
+}
+
+func TestEventCurrentTargetTracksBubblePhase(t *testing.T) {
+ const src = `
+
+
+ `
+ _, logs, _ := runJS(t, src)
+ mustHave(t, logs, "ct=parent target=child")
+}
+
func TestScriptErrorContained(t *testing.T) {
root, logs, res := runJS(t, `T
diff --git a/js/session.go b/js/session.go
index efd6fbf..b1136b6 100644
--- a/js/session.go
+++ b/js/session.go
@@ -107,6 +107,49 @@ func Begin(root *dom.Node, opt Options) *Session {
// binding reads back. Call it after each layout pass, before running scripts.
func (s *Session) SetMetrics(m Metrics) { s.b.metrics = m }
+// EventInit carries the extra fields a synthetic event needs beyond
+// type/target — which ones matter depends on typ, mirroring the real DOM
+// (Key for keydown/keyup, Data/InputType for input). Bubbles/Cancelable
+// default to false; a caller sets them per the real event's own defaults
+// (e.g. click/keydown/keyup/input/change/submit all bubble in a real
+// browser — Dispatch does not assume this for you, since it also serves
+// dispatchEvent-style callers that want an exact, spec-shaped event).
+type EventInit struct {
+ Bubbles bool
+ Cancelable bool
+ Key string // keydown / keyup
+ Data string // input
+ InputType string // input
+}
+
+// Dispatch fires a synthetic DOM event of type typ on n — the seam a host
+// outside this package uses to synthesize real user interaction (focus, a
+// keystroke, a click) against the live session, exactly as if the page's
+// own script had called element.dispatchEvent(). It bubbles per init.
+// Bubbles (see binder.dispatch) and reports whether a listener called
+// preventDefault(), so a caller (e.g. native form submission) can honor it.
+// Contained against a listener panic, like every other script execution
+// path in this package.
+func (s *Session) Dispatch(n *dom.Node, typ string, init EventInit) (defaultPrevented bool) {
+ var ev *goja.Object
+ s.guard(func() {
+ ev = s.b.newEvent(typ)
+ ev.Set("bubbles", init.Bubbles)
+ ev.Set("cancelable", init.Cancelable)
+ if init.Key != "" {
+ ev.Set("key", init.Key)
+ }
+ if init.Data != "" {
+ ev.Set("data", init.Data)
+ }
+ if init.InputType != "" {
+ ev.Set("inputType", init.InputType)
+ }
+ s.b.dispatch(n, typ, ev)
+ })
+ return ev != nil && ev.Get("defaultPrevented").ToBoolean()
+}
+
// RunInitial executes every page
+ `
+
+ live := openFixture(t, New(), src, image.Rect(0, 0, 400, 300))
+ root := live.Document().Root
+
+ emailField := findByID(root, "email")
+ passwordField := findByID(root, "password")
+ submitBtn := findByID(root, "submit")
+
+ if _, _, err := live.Focus(context.Background(), emailField); err != nil {
+ t.Fatalf("Focus(email): %v", err)
+ }
+ if _, _, err := live.Type(context.Background(), emailField, "user@test.com"); err != nil {
+ t.Fatalf("Type(email): %v", err)
+ }
+ if _, _, err := live.Focus(context.Background(), passwordField); err != nil {
+ t.Fatalf("Focus(password): %v", err)
+ }
+ if _, _, err := live.Type(context.Background(), passwordField, "hunter2"); err != nil {
+ t.Fatalf("Type(password): %v", err)
+ }
+
+ prevented, _, _, err := live.Click(context.Background(), submitBtn)
+ if err != nil {
+ t.Fatalf("Click(submit): %v", err)
+ }
+ if !prevented {
+ t.Fatal("Click(submit): want defaultPrevented=true (the handler calls preventDefault())")
+ }
+
+ status := findByID(live.Document().Root, "status")
+ if got := dom.TextContent(status); got != "ok:user@test.com" {
+ t.Fatalf("final status = %q, want %q — typed text did not reach the page's JS closure state intact", got, "ok:user@test.com")
+ }
+
+ // The DOM-level value accessors must ALSO reflect what was typed (a
+ // page that reads .value directly at submit time, the other common
+ // pattern, must work too).
+ if got := emailField.Attr["value"]; got != "user@test.com" {
+ t.Fatalf("email field value = %q, want %q", got, "user@test.com")
+ }
+ if got := passwordField.Attr["value"]; got != "hunter2" {
+ t.Fatalf("password field value = %q, want %q", got, "hunter2")
+ }
+}
+
+// TestFocusFiresBlurAndChangeOnThePreviousField covers Focus's own
+// side-effect on whatever was focused before: blur/focusout, and change
+// only because the value actually differs from when it was focused.
+func TestFocusFiresBlurAndChangeOnThePreviousField(t *testing.T) {
+ const src = `
+
+
+
+ `
+ live := openFixture(t, New(), src, image.Rect(0, 0, 400, 300))
+ root := live.Document().Root
+ a, b := findByID(root, "a"), findByID(root, "b")
+
+ ctx := context.Background()
+ if _, _, err := live.Focus(ctx, a); err != nil {
+ t.Fatalf("Focus(a): %v", err)
+ }
+ if _, _, err := live.Type(ctx, a, "x"); err != nil {
+ t.Fatalf("Type(a): %v", err)
+ }
+ // Moving focus to b must fire blur/focusout/change on a (value changed).
+ if _, _, err := live.Focus(ctx, b); err != nil {
+ t.Fatalf("Focus(b): %v", err)
+ }
+
+ if got := a.Attr["value"]; got != "x" {
+ t.Fatalf("a.value = %q, want %q", got, "x")
+ }
+ if got := dom.TextContent(findByID(live.Document().Root, "log")); got != "blur,focusout,change" {
+ t.Fatalf("blur/focusout/change log = %q, want %q", got, "blur,focusout,change")
+ }
+}
+
+// TestBlurWithNoFocusIsNoop guards the trivial case (nothing focused yet)
+// used implicitly whenever the very first Focus of a session runs.
+func TestBlurWithNoFocusIsNoop(t *testing.T) {
+ live := openFixture(t, New(), ` `, image.Rect(0, 0, 200, 200))
+ if _, _, err := live.Blur(context.Background()); err != nil {
+ t.Fatalf("Blur with nothing focused: %v", err)
+ }
+}
+
+// TestTypeOnAttributelessNode covers a node with no Attr map at all (dom.Node's
+// zero value, as opposed to one the HTML parser gave an empty-but-non-nil
+// map) — Type must allocate it rather than panic on a nil-map write.
+func TestTypeOnAttributelessNode(t *testing.T) {
+ live := openFixture(t, New(), `
`, image.Rect(0, 0, 200, 200))
+ host := findByID(live.Document().Root, "host")
+ bare := &dom.Node{Type: dom.Element, Tag: "input"} // Attr is nil
+ dom.AppendChild(host, bare)
+
+ if _, _, err := live.Type(context.Background(), bare, "x"); err != nil {
+ t.Fatalf("Type on an attributeless node: %v", err)
+ }
+ if got := bare.Attr["value"]; got != "x" {
+ t.Fatalf("value = %q, want %q", got, "x")
+ }
+}
+
+// TestTypeAfterCloseReturnsErrClosed covers Type's mid-loop error return —
+// a caller typing multiple characters must stop cleanly if the document
+// closes partway (e.g. the host window closing mid-flow).
+func TestTypeAfterCloseReturnsErrClosed(t *testing.T) {
+ live := openFixture(t, New(), ` `, image.Rect(0, 0, 200, 200))
+ a := findByID(live.Document().Root, "a")
+ live.Close()
+
+ if _, _, err := live.Type(context.Background(), a, "x"); err != ErrClosed {
+ t.Fatalf("Type after Close: err = %v, want ErrClosed", err)
+ }
+}