1package cef
2
3import (
4 "context"
5 "sync"
6 "testing"
7
8 purecef "github.com/bnema/purego-cef/cef"
9 cefmocks "github.com/bnema/purego-cef/cef/mocks"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12)
13
14// injectionHarness builds a WebView with a recording main frame. Engine is
15// nil so RunJavaScript executes synchronously without GTK dispatch.
16type injectionHarness struct {
17 wv *WebView
18 browser *cefmocks.MockBrowser
19 mu sync.Mutex
20 scripts []string
21}
22
23func newInjectionHarness(t *testing.T, uri string) *injectionHarness {
24 t.Helper()
25 return newInjectionHarnessWithID(t, uri, 7)
26}
27
28func newInjectionHarnessWithID(t *testing.T, uri string, browserID int32) *injectionHarness {
29 t.Helper()
30 h := &injectionHarness{}
31 frame := cefmocks.NewMockFrame(t)
32 frame.EXPECT().ExecuteJavaScript(mock.Anything, "", int32(0)).Run(func(script string, _ string, _ int32) {
33 h.mu.Lock()
34 defer h.mu.Unlock()
35 h.scripts = append(h.scripts, script)
36 }).Maybe()
37 h.browser = cefmocks.NewMockBrowser(t)
38 h.browser.EXPECT().GetMainFrame().Return(frame).Maybe()
39 h.browser.EXPECT().GetIdentifier().Return(browserID).Maybe()
40 h.wv = &WebView{ctx: context.Background(), browser: h.browser, documentSeq: 1}
41 h.wv.uri = uri
42 return h
43}
44
45func (h *injectionHarness) scriptCount() int {
46 h.mu.Lock()
47 defer h.mu.Unlock()
48 return len(h.scripts)
49}
50
51func testInjector() *contentInjector {
52 return &contentInjector{themeCSS: ":root{--t:1}", findHighlightCSS: ".find{--f:1}"}
53}
54
55// TestInjectionEvent_ExternalPageScriptSet counts the installation for an
56// external document: scrollbar styling, its auto-hide behavior, the
57// clipboard bridge, plus configured find CSS. No dark-mode or message
58// bridge shim belongs to external pages.
59func TestInjectionEvent_ExternalPageScriptSet(t *testing.T) {
60 h := newInjectionHarness(t, "https://example.com/page")
61 testInjector().onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, h.wv.uri))
62
63 require.Equal(t, 4, h.scriptCount(), "external install must stay a fixed small script set")
64 h.mu.Lock()
65 defer h.mu.Unlock()
66 joined := ""
67 for _, script := range h.scripts {
68 joined += script
69 }
70 require.Contains(t, joined, "dumber-scrollbar")
71 require.Contains(t, joined, "dumber-find-highlight")
72 require.NotContains(t, joined, "dumber-theme-vars")
73 require.NotContains(t, joined, "__dumber_cef_prefers_dark")
74}
75
76// TestInjectionEvent_InternalPageScriptSet counts the installation for an
77// internal document, including dark-mode handling and the message bridge.
78func TestInjectionEvent_InternalPageScriptSet(t *testing.T) {
79 h := newInjectionHarness(t, "dumb://home")
80 testInjector().onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, h.wv.uri))
81
82 require.Equal(t, 7, h.scriptCount(), "internal install must stay a fixed small script set")
83}
84
85// TestInjectionEvent_StaleBrowserSkipped reproduces an old main frame firing
86// during process swap: the GTK-dispatched callback must install nothing.
87func TestInjectionEvent_StaleBrowserSkipped(t *testing.T) {
88 h := newInjectionHarnessWithID(t, "https://example.com/a", 7)
89 event := h.wv.captureInjectionEvent(h.browser, h.wv.uri)
90
91 replacement := cefmocks.NewMockBrowser(t)
92 replacement.EXPECT().GetIdentifier().Return(int32(8)).Maybe()
93 h.wv.mu.Lock()
94 h.wv.browser = replacement
95 h.wv.mu.Unlock()
96
97 testInjector().onLoadEndForEvent(h.wv, event)
98 require.Zero(t, h.scriptCount(), "old-frame event must not install against the replacement browser")
99}
100
101// TestInjectionEvent_StaleIntentSkipped reproduces a queued callback
102// outlived by a newer navigation: installation must not run for the
103// superseded intent.
104func TestInjectionEvent_StaleIntentSkipped(t *testing.T) {
105 h := newInjectionHarness(t, "https://example.com/a")
106 event := h.wv.captureInjectionEvent(h.browser, h.wv.uri)
107
108 h.wv.mu.Lock()
109 h.wv.pendingIntentID++
110 h.wv.mu.Unlock()
111
112 testInjector().onLoadEndForEvent(h.wv, event)
113 require.Zero(t, h.scriptCount(), "superseded intent must not install")
114}
115
116// TestInjectionEvent_CurrentInstalls verifies matching identity installs once
117// for a committed document. Repeated load-end notifications for that document
118// must not repeat the GTK-to-CEF script round trips.
119func TestInjectionEvent_CurrentInstalls(t *testing.T) {
120 h := newInjectionHarness(t, "https://example.com/a")
121 ci := testInjector()
122
123 ci.onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, h.wv.uri))
124 first := h.scriptCount()
125 require.Positive(t, first)
126
127 ci.onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, h.wv.uri))
128 require.Equal(t, first, h.scriptCount(), "repeated same-document events must be coalesced")
129
130 h.wv.mu.Lock()
131 h.wv.documentSeq++
132 h.wv.mu.Unlock()
133 ci.onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, h.wv.uri))
134 require.Equal(t, 2*first, h.scriptCount(), "a new committed document must receive scripts")
135}
136
137// TestInjectionEvent_ReplacedDocumentSkipsQueuedScripts reproduces a replacement
138// navigation committing between validation and the CEF UI thread: scripts
139// pinned to the validated document must be dropped instead of landing in the
140// new document.
141func TestInjectionEvent_ReplacedDocumentSkipsQueuedScripts(t *testing.T) {
142 h := newInjectionHarness(t, "https://example.com/a")
143
144 var queued []purecef.Task
145 prevNewTask, prevPostTask := cefNewTask, cefPostTask
146 cefNewTask = func(task purecef.Task) purecef.Task { return task }
147 cefPostTask = func(_ purecef.ThreadID, task purecef.Task) int32 {
148 queued = append(queued, task)
149 return 1
150 }
151 t.Cleanup(func() { cefNewTask, cefPostTask = prevNewTask, prevPostTask })
152
153 // A non-nil engine queues scripts for the CEF UI thread instead of running
154 // them inline, which is what opens the window the pin has to close.
155 h.wv.engine = &Engine{}
156 testInjector().onLoadEndForEvent(h.wv, h.wv.captureInjectionEvent(h.browser, h.wv.uri))
157 require.NotEmpty(t, queued, "a validated load-end must queue its installation")
158
159 // The replacement document commits before the queued scripts run.
160 h.wv.mu.Lock()
161 h.wv.documentSeq++
162 h.wv.mu.Unlock()
163 for _, task := range queued {
164 task.Execute()
165 }
166
167 require.Zero(t, h.scriptCount(), "scripts pinned to a replaced document must not install")
168}
169
170// TestInjectionEvent_NilCaptureSkips verifies the conservative fallback: a
171// capture from a nil callback browser cannot prove identity, so it installs
172// nothing; the current browser gets its own load-end installation.
173func TestInjectionEvent_NilCaptureSkips(t *testing.T) {
174 h := newInjectionHarness(t, "https://example.com/a")
175 event := h.wv.captureInjectionEvent(nil, h.wv.uri)
176 require.Equal(t, noBrowserID, event.browserID)
177
178 testInjector().onLoadEndForEvent(h.wv, event)
179 require.Zero(t, h.scriptCount(), "identity-less capture must not install blind")
180}
181
182// TestInjectionEvent_NilCurrentBrowserSkips verifies dispatch against a
183// destroyed browser installs nothing instead of dereferencing it.
184func TestInjectionEvent_NilCurrentBrowserSkips(t *testing.T) {
185 h := newInjectionHarness(t, "https://example.com/a")
186 event := h.wv.captureInjectionEvent(h.browser, h.wv.uri)
187
188 h.wv.mu.Lock()
189 h.wv.browser = nil
190 h.wv.mu.Unlock()
191 testInjector().onLoadEndForEvent(h.wv, event)
192 require.Zero(t, h.scriptCount())
193}
194
195// TestInjectionEvent_StaleCallbackBrowserSkipped reproduces the exact
196// reviewer scenario: WebView state advances to a replacement browser, then
197// the OLD callback browser fires. Identity must come from the callback, so
198// the event keeps the old identifier and is rejected. Stamping the current
199// wv.browser instead would bless it with the replacement identity.
200func TestInjectionEvent_StaleCallbackBrowserSkipped(t *testing.T) {
201 h := newInjectionHarnessWithID(t, "https://example.com/a", 7)
202 oldCallbackBrowser := h.browser
203
204 replacement := cefmocks.NewMockBrowser(t)
205 replacement.EXPECT().GetIdentifier().Return(int32(8)).Maybe()
206 h.wv.mu.Lock()
207 h.wv.browser = replacement
208 h.wv.mu.Unlock()
209
210 event := h.wv.captureInjectionEvent(oldCallbackBrowser, h.wv.uri)
211 require.Equal(t, int32(7), event.browserID, "callback-sourced capture must keep the old identity")
212 testInjector().onLoadEndForEvent(h.wv, event)
213 require.Zero(t, h.scriptCount(), "old-callback event must not install against the replacement browser")
214}