vim_page_runtime_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close