1package cef
2
3import (
4 _ "embed"
5 "encoding/json"
6 "os"
7 "os/exec"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/require"
12)
13
14// vimPageRuntimeHarness is a DOM shim for the embedded runtime: three visible
15// paragraphs and one below the fold, two links, a Selection and Range with
16// Chromium-like semantics, and a fetch that records every message posted to
17// the Go bridge. See testdata/vim_page_harness.js.
18//
19//go:embed testdata/vim_page_harness.js
20var vimPageRuntimeHarness string
21
22// runVimPageRuntime executes the embedded runtime inside the harness, then
23// the given driver script, and returns every message posted to the bridge
24// plus the driver's own output appended to the "posted" array.
25func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
26 t.Helper()
27 node, err := exec.LookPath("node")
28 if err != nil {
29 if os.Getenv("DUMBER_REQUIRE_NODE") == "1" {
30 t.Fatal("node is required (DUMBER_REQUIRE_NODE=1) to execute the vim page runtime")
31 }
32 t.Skip("node is required to execute the vim page runtime")
33 }
34 script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +
35 "\nprocess.stdout.write(JSON.stringify(posted));"
36 output, err := exec.Command(node, "-e", script).CombinedOutput()
37 require.NoError(t, err, "runtime failed: %s", output)
38 var posted []map[string]any
39 require.NoError(t, json.Unmarshal(output, &posted), "runtime output: %s", output)
40 return posted
41}
42
43// named returns the driver snapshot recorded under name.
44func named(t *testing.T, posted []map[string]any, name string) map[string]any {
45 t.Helper()
46 for _, entry := range posted {
47 if entry["n"] == name {
48 return entry
49 }
50 }
51 require.FailNow(t, "snapshot not recorded", name)
52 return nil
53}
54
55// bridgeMessages returns the runtime's own bridge messages, without snapshots.
56func bridgeMessages(posted []map[string]any) []map[string]any {
57 var out []map[string]any
58 for _, entry := range posted {
59 if _, ok := entry["token"]; ok {
60 out = append(out, entry)
61 }
62 }
63 return out
64}
65
66func modeSequence(posted []map[string]any) []any {
67 var out []any
68 for _, entry := range bridgeMessages(posted) {
69 if entry["type"] == "mode" {
70 out = append(out, entry["mode"])
71 }
72 }
73 return out
74}
75
76func TestVimPageRuntimeVisualStartsWithTextAnchorHints(t *testing.T) {
77 posted := runVimPageRuntime(t, `
78window.__dumberVimPage.start("tok", { kind: "visual", color: "#fbbf24" });
79posted.push({ n: "hints", labels: hintLabels(), selection: selection.rangeCount, caret: cursor() !== null });
80`)
81 hints := named(t, posted, "hints")
82 require.Equal(t, []any{"s", "a", "d"}, hints["labels"], "one label per visible text block, none for text below the fold")
83 require.EqualValues(t, 0, hints["selection"])
84 require.Equal(t, false, hints["caret"], "no caret until an anchor is picked")
85 require.Equal(t, []any{"hints"}, modeSequence(posted))
86}
87
88func TestVimPageRuntimeTextAnchorLabelPlacesCaret(t *testing.T) {
89 posted := runVimPageRuntime(t, `
90startCaret("tok", "a");
91S("caret", { hints: hintLabels(), cursor: cursor() && cursor().style });
92`)
93 caret := named(t, posted, "caret")
94 require.Empty(t, caret["text"])
95 require.Equal(t, "1:0", caret["anchor"], "the label puts the caret at the start of that text")
96 require.Equal(t, "1:0", caret["focus"])
97 require.Empty(t, caret["hints"], "the hint overlay is gone once the caret is placed")
98 style := caret["cursor"].(map[string]any)
99 require.Equal(t, "fixed", style["position"])
100 require.Equal(t, "#fbbf24", style["background"], "the caret uses the accent color")
101 require.Equal(t, "none", style["pointer-events"])
102 require.Equal(t, "2147483647", style["z-index"])
103 require.Equal(t, "8px", style["left"])
104 require.Equal(t, "37px", style["top"], "the caret is taller than the line, centered on it")
105 require.Equal(t, "26px", style["height"])
106 require.Equal(t, "4px", style["width"])
107 require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
108 require.Empty(t, bridgeMessagesOfType(posted, "end"))
109}
110
111func bridgeMessagesOfType(posted []map[string]any, messageType string) []map[string]any {
112 var out []map[string]any
113 for _, message := range bridgeMessages(posted) {
114 if message["type"] == messageType {
115 out = append(out, message)
116 }
117 }
118 return out
119}
120
121func TestVimPageRuntimeEscapeInTextAnchorHintsPicksFirstLongText(t *testing.T) {
122 posted := runVimPageRuntime(t, `
123window.__dumberVimPage.start("tok", { kind: "visual" });
124vp().key("tok", "<Escape>");
125S("caret");
126`)
127 caret := named(t, posted, "caret")
128 require.Equal(t, "1:0", caret["focus"], "the first visible text of at least 50 characters is chosen over short banners")
129 require.Equal(t, []any{"hints", "caret"}, modeSequence(posted))
130}
131
132func TestVimPageRuntimeEscapeInTextAnchorHintsFallsBackToAnyVisibleText(t *testing.T) {
133 posted := runVimPageRuntime(t, `
134second.text.data = "short";
135window.__dumberVimPage.start("tok", { kind: "visual" });
136vp().key("tok", "<Escape>");
137S("caret");
138`)
139 require.Equal(t, "0:0", named(t, posted, "caret")["focus"])
140}
141
142func TestVimPageRuntimeVisualEndsWithReasonWhenNoVisibleText(t *testing.T) {
143 posted := runVimPageRuntime(t, `
144for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
145window.__dumberVimPage.start("tok", { kind: "visual" });
146`)
147 require.Equal(t, []map[string]any{{"token": "tok", "type": "end", "reason": "no-visible-text"}}, bridgeMessages(posted))
148}
149
150func TestVimPageRuntimeEscapeFallbackEndsWhenNothingVisible(t *testing.T) {
151 posted := runVimPageRuntime(t, `
152window.__dumberVimPage.start("tok", { kind: "visual" });
153for (const t of texts) t.parentElement.rect = rect(10, 5000, 600, 20);
154vp().key("tok", "<Escape>");
155`)
156 ends := bridgeMessagesOfType(posted, "end")
157 require.Len(t, ends, 1)
158 require.Equal(t, "no-visible-text", ends[0]["reason"])
159}
160
161func TestVimPageRuntimeVisibleSelectionStartsVisualDirectly(t *testing.T) {
162 posted := runVimPageRuntime(t, `
163selection.setBaseAndExtent(first.text, 0, first.text, 5);
164window.__dumberVimPage.start("tok", { kind: "visual" });
165S("start", { hints: hintLabels(), cursor: cursor() !== null });
166vp().key("tok", "l");
167S("extended");
168`)
169 start := named(t, posted, "start")
170 require.Equal(t, "Alpha", start["text"], "an existing visible selection is used as is")
171 require.Empty(t, start["hints"])
172 require.Equal(t, true, start["cursor"])
173 require.Equal(t, "Alpha ", named(t, posted, "extended")["text"])
174 require.Equal(t, []any{"visual"}, modeSequence(posted))
175}
176
177func TestVimPageRuntimeSelectionOutsideViewportIsIgnored(t *testing.T) {
178 posted := runVimPageRuntime(t, `
179selection.setBaseAndExtent(far.text, 0, far.text, 3);
180window.__dumberVimPage.start("tok", { kind: "visual" });
181posted.push({ n: "hints", labels: hintLabels() });
182`)
183 require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"])
184 require.Equal(t, []any{"hints"}, modeSequence(posted))
185}
186
187func TestVimPageRuntimeCaretMotionsMoveWithoutSelecting(t *testing.T) {
188 tests := []struct {
189 name string
190 start string // hint label
191 keys []string
192 want string // focus
193 }{
194 {"l", "s", []string{"l", "l"}, "0:2"},
195 {"counted l", "s", []string{"3", "l"}, "0:3"},
196 {"h clamps", "s", []string{"l", "h", "h"}, "0:0"},
197 {"w", "s", []string{"w"}, "0:6"},
198 {"2w", "s", []string{"2", "w"}, "0:12"},
199 {"w crosses line", "s", []string{"3", "w"}, "1:0"},
200 {"e", "s", []string{"e"}, "0:5"},
201 {"b", "s", []string{"w", "w", "b"}, "0:6"},
202 {"b at start", "s", []string{"b"}, "0:0"},
203 {"$", "s", []string{"$"}, "0:19"},
204 {"0", "s", []string{"$", "0"}, "0:0"},
205 {"^", "s", []string{"$", "^"}, "0:0"},
206 {"j", "s", []string{"l", "l", "j"}, "1:2"},
207 {"k", "a", []string{"l", "k"}, "0:1"},
208 {"G", "s", []string{"G"}, "3:18"},
209 {"gg", "a", []string{"G", "g", "g"}, "0:0"},
210 }
211 for _, tt := range tests {
212 t.Run(tt.name, func(t *testing.T) {
213 keys, err := json.Marshal(tt.keys)
214 require.NoError(t, err)
215 posted := runVimPageRuntime(t, `
216startCaret("tok", `+jsString(tt.start)+`);
217keys("tok", ...`+string(keys)+`);
218S("end");
219`)
220 end := named(t, posted, "end")
221 require.Equal(t, tt.want, end["focus"])
222 require.Equal(t, end["focus"], end["anchor"], "the caret stays collapsed")
223 require.Empty(t, end["text"])
224 require.Empty(t, bridgeMessagesOfType(posted, "end"))
225 })
226 }
227}
228
229// Sentence and paragraph motions are native Selection.modify granularities
230// the DOM shim cannot emulate, so the contract checked here is the call: the
231// alter mode follows the phase and the direction and granularity are exact.
232func TestVimPageRuntimeNativeGranularityMotions(t *testing.T) {
233 tests := []struct {
234 key string
235 direction string
236 granularity string
237 }{
238 {"(", "backward", "sentence"},
239 {")", "forward", "sentence"},
240 {"{", "backward", "paragraph"},
241 {"}", "forward", "paragraph"},
242 {"0", "backward", "lineboundary"},
243 {"$", "forward", "lineboundary"},
244 {"G", "forward", "documentboundary"},
245 }
246 for _, tt := range tests {
247 for _, phase := range []struct{ name, setup, alter string }{
248 {"caret", "", "move"},
249 {"visual", `keys("tok", "v");`, "extend"},
250 } {
251 t.Run(phase.name+" "+tt.key, func(t *testing.T) {
252 posted := runVimPageRuntime(t, `
253startCaret("tok", "s");
254`+phase.setup+`
255modifyCalls.length = 0;
256keys("tok", `+jsString(tt.key)+`);
257posted.push({ n: "calls", calls: modifyCalls });
258`)
259 require.Equal(t, []any{[]any{phase.alter, tt.direction, tt.granularity}}, named(t, posted, "calls")["calls"])
260 })
261 }
262 }
263}
264
265func TestVimPageRuntimeWordMotionsSkipPunctuation(t *testing.T) {
266 posted := runVimPageRuntime(t, `
267startCaret("tok", "d");
268keys("tok", "w");
269S("w1");
270keys("tok", "w");
271S("w2");
272keys("tok", "b");
273S("b1");
274`)
275 // "Third line, end.": w from "Third" -> "line"; w -> "end" (the comma and
276 // space are skipped); b -> back to "line".
277 require.Equal(t, "2:6", named(t, posted, "w1")["focus"])
278 require.Equal(t, "2:12", named(t, posted, "w2")["focus"])
279 require.Equal(t, "2:6", named(t, posted, "b1")["focus"])
280}
281
282func TestVimPageRuntimeCaretMotionScrollsFocusIntoView(t *testing.T) {
283 posted := runVimPageRuntime(t, `
284startCaret("tok", "s");
285scrolls.length = 0;
286keys("tok", "G");
287posted.push({ n: "scrolled", scrolls });
288`)
289 scrolled := named(t, posted, "scrolled")["scrolls"].([]any)
290 require.NotEmpty(t, scrolled, "moving the caret below the fold scrolls it into view")
291 require.Greater(t, scrolled[0].([]any)[1].(float64), float64(1000))
292}
293
294func TestVimPageRuntimeCaretFollowsScrollAndIsRemovedOnEnd(t *testing.T) {
295 posted := runVimPageRuntime(t, `
296startCaret("tok", "s");
297const bar = cursor();
298first.p.rect = rect(10, -300, 600, 20); // the page scrolled the paragraph away
299fire("scroll");
300flushFrames();
301const hiddenWhileOffscreen = bar.style.display;
302first.p.rect = rect(10, 100, 600, 20);
303fire("scroll");
304flushFrames();
305posted.push({ n: "follow", hidden: hiddenWhileOffscreen, top: bar.style.top, display: bar.style.display });
306keys("tok", "<Escape>");
307posted.push({ n: "cleanup", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, scrollListeners: (listeners.scroll || []).length, resize: (listeners.resize || []).length, selection: selection.rangeCount });
308`)
309 follow := named(t, posted, "follow")
310 require.Equal(t, "none", follow["hidden"], "the caret hides while its text is off screen")
311 require.Equal(t, "97px", follow["top"], "the caret follows its text when the page scrolls")
312 require.Empty(t, follow["display"])
313 cleanup := named(t, posted, "cleanup")
314 require.Equal(t, true, cleanup["caret"], "the caret overlay is removed on cleanup")
315 require.EqualValues(t, 0, cleanup["styles"])
316 require.EqualValues(t, 0, cleanup["scrollListeners"])
317 require.EqualValues(t, 0, cleanup["resize"])
318 require.EqualValues(t, 0, cleanup["selection"])
319}
320
321func TestVimPageRuntimeVisualFromCaretExtendsAndYankReturnsToCaret(t *testing.T) {
322 posted := runVimPageRuntime(t, `
323startCaret("tok", "s");
324keys("tok", "v");
325S("v");
326keys("tok", "l", "l");
327S("extended");
328keys("tok", "w");
329S("word");
330keys("tok", "y");
331S("after");
332keys("tok", "l");
333S("moved");
334keys("tok", "y");
335S("caretYank");
336keys("tok", "v", "e");
337S("second");
338keys("tok", "<Return>");
339S("afterReturn");
340`)
341 require.Equal(t, "A", named(t, posted, "v")["text"], "v starts charwise from the caret")
342 require.Equal(t, "Alp", named(t, posted, "extended")["text"])
343 require.Equal(t, "Alpha ", named(t, posted, "word")["text"])
344
345 after := named(t, posted, "after")
346 require.Empty(t, after["text"], "y returns to a collapsed caret")
347 require.Equal(t, "0:5", after["focus"], "on the last selected character, where v put the caret")
348 require.Equal(t, after["focus"], after["anchor"])
349 require.Equal(t, "0:6", named(t, posted, "moved")["focus"], "the caret keeps moving after a yank")
350 require.Equal(t, "0:6", named(t, posted, "caretYank")["focus"], "y in caret mode does nothing")
351
352 copies := bridgeMessagesOfType(posted, "copy")
353 require.Len(t, copies, 2, "y in caret mode copies nothing; Enter chains a second copy")
354 require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, copies[0])
355 require.Equal(t, "bravo", copies[1]["text"], "v includes the character under the caret and e extends to the word end")
356 require.Empty(t, named(t, posted, "afterReturn")["text"])
357 require.Empty(t, bridgeMessagesOfType(posted, "end"), "copying never ends the interaction")
358 require.Equal(t, []any{"hints", "caret", "visual", "caret", "visual", "caret"}, modeSequence(posted))
359}
360
361func TestVimPageRuntimeVisualOSwapsEnds(t *testing.T) {
362 posted := runVimPageRuntime(t, `
363startCaret("tok", "s");
364keys("tok", "l", "l", "l", "v");
365S("before");
366keys("tok", "o");
367S("swapped");
368keys("tok", "h", "h");
369S("shrunk");
370keys("tok", "o", "l");
371S("back");
372`)
373 before := named(t, posted, "before")
374 require.Equal(t, "0:3", before["anchor"])
375 require.Equal(t, "0:4", before["focus"])
376 swapped := named(t, posted, "swapped")
377 require.Equal(t, "0:4", swapped["anchor"])
378 require.Equal(t, "0:3", swapped["focus"])
379 require.Equal(t, "0:4", named(t, posted, "shrunk")["anchor"])
380 require.Equal(t, "0:1", named(t, posted, "shrunk")["focus"])
381 require.Equal(t, "lph", named(t, posted, "shrunk")["text"])
382 back := named(t, posted, "back")
383 require.Equal(t, "0:1", back["anchor"])
384 require.Equal(t, "0:5", back["focus"], "after o the motions extend the other end")
385}
386
387func TestVimPageRuntimeVisualLine(t *testing.T) {
388 posted := runVimPageRuntime(t, `
389startCaret("tok", "a");
390keys("tok", "l", "l");
391keys("tok", "V");
392S("line");
393keys("tok", "j");
394S("down");
395keys("tok", "k", "k");
396S("up");
397keys("tok", "o");
398S("swapped");
399keys("tok", "y");
400S("yanked");
401`)
402 require.Equal(t, "The quick brown fox jumps over the lazy dog near the river", named(t, posted, "line")["text"], "V selects the whole line")
403 require.Equal(t, "The quick brown fox jumps over the lazy dog near the river\nThird line, end.", named(t, posted, "down")["text"])
404 up := named(t, posted, "up")
405 require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", up["text"], "motions extend by whole lines")
406 require.Equal(t, "1:58", up["anchor"])
407 require.Equal(t, "0:0", up["focus"])
408 require.Equal(t, "0:0", named(t, posted, "swapped")["anchor"], "o swaps the ends")
409 yanked := named(t, posted, "yanked")
410 require.Empty(t, yanked["text"])
411 require.Equal(t, yanked["focus"], yanked["anchor"], "y returns to a caret at the focus")
412 copies := bridgeMessagesOfType(posted, "copy")
413 require.Len(t, copies, 1)
414 require.Equal(t, "Alpha bravo charlie\nThe quick brown fox jumps over the lazy dog near the river", copies[0]["text"])
415 require.Equal(t, []any{"hints", "caret", "visual-line", "caret"}, modeSequence(posted))
416}
417
418func TestVimPageRuntimeModeSwitchingKeys(t *testing.T) {
419 posted := runVimPageRuntime(t, `
420startCaret("tok", "s");
421keys("tok", "l", "l");
422keys("tok", "v", "l");
423S("visual");
424keys("tok", "V");
425S("line");
426keys("tok", "v");
427S("visualAgain");
428keys("tok", "V");
429keys("tok", "V");
430S("caretFromLine");
431keys("tok", "v");
432keys("tok", "v");
433S("caretFromVisual");
434keys("tok", "v", "l", "<Escape>");
435S("escapeVisual");
436keys("tok", "V", "<Escape>");
437S("escapeLine");
438`)
439 require.Equal(t, "ph", named(t, posted, "visual")["text"], "v includes the character under the caret, then l extends")
440 require.Equal(t, "Alpha bravo charlie", named(t, posted, "line")["text"])
441 require.Equal(t, "Alpha bravo charlie", named(t, posted, "visualAgain")["text"], "v in visual line switches to charwise without changing the range")
442 require.Empty(t, named(t, posted, "caretFromLine")["text"], "V in visual line goes back to caret")
443 require.Empty(t, named(t, posted, "caretFromVisual")["text"], "v in visual goes back to caret")
444 require.Empty(t, named(t, posted, "escapeVisual")["text"], "Escape in visual goes back to caret")
445 require.Empty(t, named(t, posted, "escapeLine")["text"], "Escape in visual line goes back to caret")
446 require.Empty(t, bridgeMessagesOfType(posted, "end"), "none of these end the interaction")
447 require.Equal(t, []any{
448 "hints", "caret", "visual", "visual-line", "visual", "visual-line", "caret",
449 "visual", "caret", "visual", "caret", "visual-line", "caret",
450 }, modeSequence(posted))
451}
452
453func TestVimPageRuntimeEscapeInCaretEndsInteraction(t *testing.T) {
454 posted := runVimPageRuntime(t, `
455startCaret("tok", "s");
456keys("tok", "l", "<Escape>");
457posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null });
458`)
459 require.Equal(t, []map[string]any{{"token": "tok", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
460 after := named(t, posted, "after")
461 require.EqualValues(t, 0, after["selection"])
462 require.Equal(t, true, after["caret"])
463}
464
465func TestVimPageRuntimeCancelRemovesCaretAndSelection(t *testing.T) {
466 posted := runVimPageRuntime(t, `
467startCaret("tok", "s");
468keys("tok", "v", "l");
469vp().cancel("tok");
470posted.push({ n: "after", selection: selection.rangeCount, caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length });
471`)
472 after := named(t, posted, "after")
473 require.EqualValues(t, 0, after["selection"])
474 require.Equal(t, true, after["caret"])
475 require.EqualValues(t, 0, after["styles"])
476 require.Empty(t, bridgeMessagesOfType(posted, "end"), "cancel is Go-initiated and reports nothing")
477}
478
479func TestVimPageRuntimeCancelRemovesHintOverlay(t *testing.T) {
480 posted := runVimPageRuntime(t, `
481window.__dumberVimPage.start("tok", { kind: "visual" });
482vp().cancel("tok");
483posted.push({ n: "after", labels: hintLabels() });
484`)
485 require.Empty(t, named(t, posted, "after")["labels"])
486}
487
488func TestVimPageRuntimeKeyErrorReleasesCapture(t *testing.T) {
489 posted := runVimPageRuntime(t, `
490startCaret("tok", "s");
491selection.modify = () => { throw new Error("page quirk"); };
492keys("tok", "l");
493`)
494 ends := bridgeMessagesOfType(posted, "end")
495 require.Len(t, ends, 1, "a failing motion must not leave the keyboard captured")
496 require.Equal(t, "error", ends[0]["reason"])
497}
498
499func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {
500 posted := runVimPageRuntime(t, `
501const vpage = window.__dumberVimPage;
502vpage.start("a", { kind: "hint-follow" });
503posted.push({ n: "labels", labels: hintLabels() });
504vpage.key("a", "a");
505vpage.start("b", { kind: "hint-yank-url" });
506vpage.key("b", "s");
507vpage.start("c", { kind: "hint-follow-new" });
508vpage.key("c", "a");
509vpage.start("d", { kind: "hint-follow" });
510vpage.key("d", "<Escape>");
511`)
512 require.ElementsMatch(t, []any{"s", "a"}, named(t, posted, "labels")["labels"], "two visible links get one-letter labels")
513 require.Equal(t, "https://example.com/two", recordedField(posted, "clicked"), "follow clicks the chosen link")
514 messages := bridgeMessages(posted)
515 require.Equal(t, []map[string]any{
516 {"token": "a", "type": "end"},
517 {"token": "b", "type": "copy", "text": "file:///one.html"},
518 {"token": "c", "type": "open-new", "url": "https://example.com/two"},
519 {"token": "d", "type": "end"},
520 }, messages)
521 require.NotContains(t, modeSequence(posted), "caret", "link hints never report caret modes")
522}
523
524// recordedField returns a field recorded by a harness side effect (such as a click).
525func recordedField(posted []map[string]any, key string) any {
526 for _, entry := range posted {
527 if value, ok := entry[key]; ok {
528 return value
529 }
530 }
531 return nil
532}
533
534func TestVimPageRuntimeYankParagraph(t *testing.T) {
535 posted := runVimPageRuntime(t, `
536first.p.innerText = first.text.data;
537window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
538`)
539 messages := bridgeMessages(posted)
540 require.Len(t, messages, 1)
541 require.Equal(t, "copy", messages[0]["type"])
542 require.True(t, strings.HasPrefix(messages[0]["text"].(string), "Alpha"))
543}
544
545func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {
546 posted := runVimPageRuntime(t, `
547startCaret("live", "s");
548vp().key("stale", "j");
549posted.push({ n: "live", selection: selection.rangeCount });
550`)
551 require.Equal(t, []map[string]any{{"token": "stale", "type": "end"}}, bridgeMessagesOfType(posted, "end"))
552 require.EqualValues(t, 1, named(t, posted, "live")["selection"], "a stale key must not disturb the live interaction")
553}
554
555// Entering visual selects the character under the caret and leaving it must
556// put the caret back on that character; the caret used to move one position
557// right on every v then Escape.
558func TestVimPageRuntimeVisualRoundTripDoesNotDriftCaret(t *testing.T) {
559 posted := runVimPageRuntime(t, `
560startCaret("tok", "s");
561keys("tok", "l", "l");
562S("start");
563for (let i = 0; i < 5; i++) keys("tok", "v", "<Escape>");
564S("afterEscapes");
565for (let i = 0; i < 3; i++) keys("tok", "v", "v");
566S("afterToggles");
567for (let i = 0; i < 3; i++) keys("tok", "v", "y");
568S("afterYanks");
569keys("tok", "V", "V");
570S("afterLine");
571`)
572 start := named(t, posted, "start")
573 require.Equal(t, "0:2", start["focus"])
574 for _, name := range []string{"afterEscapes", "afterToggles", "afterYanks"} {
575 after := named(t, posted, name)
576 require.Equal(t, start["focus"], after["focus"], name)
577 require.Equal(t, start["anchor"], after["anchor"], name)
578 require.Empty(t, after["text"], name)
579 }
580 copies := bridgeMessagesOfType(posted, "copy")
581 require.Len(t, copies, 3)
582 for _, c := range copies {
583 require.Equal(t, "p", c["text"], "each yank copies the character under the caret")
584 }
585 require.Equal(t, "0:18", named(t, posted, "afterLine")["focus"], "V then V returns to the end of the line")
586}
587
588func TestVimPageRuntimeVisualRoundTripAtEndOfTextKeepsCaret(t *testing.T) {
589 posted := runVimPageRuntime(t, `
590startCaret("tok", "d");
591keys("tok", "G");
592S("end");
593keys("tok", "v");
594S("visual");
595keys("tok", "<Escape>");
596S("back");
597`)
598 end := named(t, posted, "end")
599 require.Equal(t, "3:18", end["focus"])
600 visual := named(t, posted, "visual")
601 require.Equal(t, "d", visual["text"], "at the end of the text v selects the last character")
602 require.Equal(t, "3:18", visual["focus"])
603 require.Equal(t, "3:17", named(t, posted, "back")["focus"], "the caret returns onto the last character")
604}
605
606// Motions must not read the whole selection text per step: that made long
607// selections quadratic.
608func TestVimPageRuntimeMotionsDoNotReadSelectionText(t *testing.T) {
609 posted := runVimPageRuntime(t, `
610startCaret("tok", "s");
611keys("tok", "v");
612toStringCalls = 0;
613keys("tok", "w", "w", "e", "b", "$", "^");
614posted.push({ n: "reads", calls: toStringCalls });
615S("sel");
616`)
617 require.EqualValues(t, 0, named(t, posted, "reads")["calls"])
618 require.NotEmpty(t, named(t, posted, "sel")["focus"])
619}
620
621func TestVimPageRuntimeStartAndCancelFailuresReleaseCapture(t *testing.T) {
622 posted := runVimPageRuntime(t, `
623const realGetSelection = window.getSelection;
624window.getSelection = () => { throw new Error("page quirk"); };
625window.__dumberVimPage.start("bad", { kind: "visual" });
626window.getSelection = realGetSelection;
627posted.push({ n: "afterStart", hints: hintLabels().length, caret: cursor() === null });
628
629startCaret("tok", "s");
630selection.removeAllRanges = () => { throw new Error("page quirk"); };
631vp().cancel("tok");
632posted.push({ n: "afterCancel", caret: cursor() === null, styles: overlays("data-dumber-vim-visual").length, listeners: (listeners.scroll || []).length });
633`)
634 ends := bridgeMessagesOfType(posted, "end")
635 require.Equal(t, []map[string]any{{"token": "bad", "type": "end", "reason": "error"}}, ends, "a failing start reports the end")
636 afterStart := named(t, posted, "afterStart")
637 require.EqualValues(t, 0, afterStart["hints"])
638 cancel := named(t, posted, "afterCancel")
639 require.Equal(t, true, cancel["caret"], "cancel removes the overlay even when the selection cannot be cleared")
640 require.EqualValues(t, 0, cancel["styles"])
641 require.EqualValues(t, 0, cancel["listeners"])
642}
643
644func TestVimPageRuntimePageHideCleansUp(t *testing.T) {
645 posted := runVimPageRuntime(t, `
646startCaret("tok", "s");
647fire("pagehide");
648posted.push({ n: "gone", caret: cursor() === null, scroll: (listeners.scroll || []).length, frames: frames.size });
649fire("pagehide"); // without an interaction it is a no-op
650`)
651 gone := named(t, posted, "gone")
652 require.Equal(t, true, gone["caret"])
653 require.EqualValues(t, 0, gone["scroll"])
654 require.Empty(t, bridgeMessagesOfType(posted, "end"), "the document is leaving; nothing is reported")
655}
656
657// SVG anchors expose href as an SVGAnimatedString and have no click().
658func TestVimPageRuntimeSVGLinks(t *testing.T) {
659 posted := runVimPageRuntime(t, `
660const svg = body.appendChild(new SVGAElement_("../rel.html"));
661svg.rect = rect(150, 120, 40, 20);
662const w = window.__dumberVimPage;
663w.start("y", { kind: "hint-yank-url" });
664w.key("y", "d");
665w.start("n", { kind: "hint-follow-new" });
666w.key("n", "d");
667w.start("f", { kind: "hint-follow" });
668w.key("f", "d");
669`)
670 require.Equal(t, []map[string]any{
671 {"token": "y", "type": "copy", "text": "https://example.com/rel.html"},
672 {"token": "n", "type": "open-new", "url": "https://example.com/rel.html"},
673 {"token": "f", "type": "end"},
674 }, bridgeMessages(posted), "relative SVG hrefs resolve against the document")
675 require.Equal(t, "click", recordedField(posted, "dispatched"), "following dispatches a click when the element has no click()")
676 require.Equal(t, true, recordedField(posted, "bubbles"))
677 require.Equal(t, true, recordedField(posted, "cancelable"))
678}
679
680// Chromium reports Selection positions inside a shadow tree relative to the
681// host, so shadow text never anchors the caret, while link hints and text
682// objects keep reaching into shadow roots.
683func TestVimPageRuntimeShadowTextIsNotACaretAnchor(t *testing.T) {
684 posted := runVimPageRuntime(t, `
685shadowParagraph("Shadow text that is visible", 200);
686window.__dumberVimPage.start("tok", { kind: "visual" });
687posted.push({ n: "hints", labels: hintLabels() });
688vp().key("tok", "<Escape>");
689S("fallback");
690vp().cancel("tok");
691
692for (const t of texts) if (t !== texts[texts.length - 1]) t.parentElement.rect = rect(10, 5000, 600, 20);
693window.__dumberVimPage.start("only", { kind: "visual" });
694`)
695 require.Equal(t, []any{"s", "a", "d"}, named(t, posted, "hints")["labels"], "no label for the shadow paragraph")
696 require.Equal(t, "1:0", named(t, posted, "fallback")["focus"], "the fallback anchor ignores shadow text too")
697 ends := bridgeMessagesOfType(posted, "end")
698 require.Len(t, ends, 1)
699 require.Equal(t, "no-visible-text", ends[0]["reason"], "shadow text alone offers no anchor")
700}
701
702func TestVimPageRuntimeYankObjectStillReadsShadowDOM(t *testing.T) {
703 posted := runVimPageRuntime(t, `
704for (const t of texts.slice(0, 4)) t.parentElement.rect = rect(10, 5000, 600, 20);
705const shadow = shadowParagraph("Inside shadow", 200);
706shadow.p.innerText = "Inside shadow";
707window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
708`)
709 require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": "Inside shadow"}}, bridgeMessages(posted))
710}
711
712// Off-screen elements are rejected from their bounding box, before the costly
713// closest() and range measurements run.
714func TestVimPageRuntimeSkipsOffscreenWork(t *testing.T) {
715 posted := runVimPageRuntime(t, `
716for (let i = 0; i < 5; i++) {
717 const link = body.appendChild(new HTMLAnchorElement("https://example.com/" + i));
718 link.rect = rect(10, 4000 + i * 30, 40, 20);
719}
720closestCalls = 0;
721window.__dumberVimPage.start("h", { kind: "hint-follow" });
722posted.push({ n: "hints", closest: closestCalls, labels: hintLabels().length });
723vp().cancel("h");
724rangeRectCalls = 0;
725window.__dumberVimPage.start("v", { kind: "visual" });
726posted.push({ n: "anchors", ranges: rangeRectCalls, labels: hintLabels().length });
727`)
728 hints := named(t, posted, "hints")
729 require.EqualValues(t, 2, hints["labels"])
730 require.EqualValues(t, 2, hints["closest"], "only the two on-screen links reach closest()")
731 anchors := named(t, posted, "anchors")
732 require.EqualValues(t, 3, anchors["labels"])
733 require.EqualValues(t, 3, anchors["ranges"], "the paragraph below the fold is never measured")
734}
735
736// Scroll and resize repaint the caret once per animation frame, and a pending
737// frame is canceled with the interaction.
738func TestVimPageRuntimeBatchesViewportUpdatesPerFrame(t *testing.T) {
739 posted := runVimPageRuntime(t, `
740startCaret("tok", "s");
741const bar = cursor();
742first.p.rect = rect(10, 100, 600, 20);
743for (let i = 0; i < 10; i++) { fire("scroll"); fire("resize"); }
744const pending = frames.size;
745const stale = bar.style.top;
746flushFrames();
747posted.push({ n: "batched", pending, stale, top: bar.style.top });
748fire("scroll");
749const queued = frames.size;
750vp().cancel("tok");
751posted.push({ n: "canceled", queued, left: frames.size });
752`)
753 batched := named(t, posted, "batched")
754 require.EqualValues(t, 1, batched["pending"], "a burst of events schedules one repaint")
755 require.NotEqual(t, batched["stale"], batched["top"], "the repaint happens in the frame")
756 canceled := named(t, posted, "canceled")
757 require.EqualValues(t, 1, canceled["queued"])
758 require.EqualValues(t, 0, canceled["left"], "cleanup cancels a pending frame")
759}
760
761// The bridge carries UTF-8: accents, CJK and astral characters must reach Go
762// exactly as the page holds them.
763func TestVimPageRuntimeCopiesNonASCIIText(t *testing.T) {
764 const text = "héllo wörld — 日本語 😀 ñ"
765 posted := runVimPageRuntime(t, `
766first.p.innerText = `+jsString(text)+`;
767window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
768`)
769 require.Equal(t, []map[string]any{{"token": "tok", "type": "copy", "text": text}}, bridgeMessages(posted))
770}