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 `) + 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) + } +}