Open History to see the last commit
1import type { ReactNode } from "react";
2import { describe, expect, it, vi } from "vite-plus/test";
3import { cdp, userEvent } from "vite-plus/test/browser";
4import "~/index.css";
5
6import { render } from "../../../test/render";
7
8import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting";
9
10// The pool starts its workers before showing any code, which can outlast a cold CI browser.
11vi.mock(import("~/components/DiffWorkerPoolProvider"), () => ({
12 DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}</>,
13}));
14
15import ReadOnlySourcePreview from "./ReadOnlySourcePreview";
16
17const LINE_COUNT = 301;
18const text = `${Array.from({ length: LINE_COUNT }, (_, index) => `line ${index + 1}`).join("\n")}\n`;
19
20// The browser ignores a copy event's data unless the event is cancelled, and copies the selection itself.
21function copiedText(target: Element) {
22 const clipboardData = new DataTransfer();
23 const handled = !target.dispatchEvent(
24 new ClipboardEvent("copy", { clipboardData, bubbles: true, cancelable: true, composed: true }),
25 );
26 return handled ? clipboardData.getData("text/plain") : null;
27}
28
29describe("ReadOnlySourcePreview", () => {
30 it("copies the whole file after Select All, wherever the preview is scrolled", async () => {
31 // File paints nothing until the shared highlighter has loaded; once it has, mount renders synchronously.
32 await getSyntaxHighlighterPromise("text");
33 await using screen = await render(
34 <div className="flex h-[400px] flex-col">
35 <ReadOnlySourcePreview name="notes.txt" text={text} wordWrap={false} />
36 </div>,
37 );
38 const root = screen.container.querySelector("diffs-container")!.shadowRoot!;
39 const rows = () => root.querySelectorAll("[data-line]");
40 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
41 // Only rows near the viewport are mounted, so the browser's own copy would stop there.
42 expect(rows().length).toBeLessThan(LINE_COUNT);
43
44 await userEvent.click(rows()[0]!);
45 await userEvent.keyboard("{ControlOrMeta>}a{/ControlOrMeta}");
46 expect(copiedText(rows()[0]!)).toBe(text);
47
48 const scroller = screen.container.querySelector(".file-preview-virtualizer")!;
49 scroller.scrollTop = scroller.scrollHeight;
50 await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"));
51 expect(copiedText(rows()[0]!)).toBe(text);
52
53 document.getSelection()!.removeAllRanges();
54 await userEvent.click(rows()[rows().length - 1]!);
55 await userEvent.keyboard("{ControlOrMeta>}a{/ControlOrMeta}");
56 expect(copiedText(rows()[0]!)).toBe(text);
57
58 // A selection that stops inside the mounted rows at either end stays the browser's to copy.
59 const pre = root.querySelector("pre")!;
60 const firstText = rows()[0]!.querySelector("span")!.firstChild!;
61 const lastText = rows()[rows().length - 1]!.querySelector("span")!.firstChild!;
62 document.getSelection()!.setBaseAndExtent(pre, 0, lastText, 4);
63 expect(copiedText(rows()[0]!)).toBeNull();
64 document.getSelection()!.setBaseAndExtent(firstText, 4, pre, pre.childNodes.length);
65 expect(copiedText(rows()[0]!)).toBeNull();
66 });
67
68 it.each([
69 ["a lone carriage return", "\r"],
70 ["a carriage return and line feed", "\r\n"],
71 ])("copies the whole of a file whose lines end in %s", async (_name, ending) => {
72 const contents = text.replaceAll("\n", ending);
73 await getSyntaxHighlighterPromise("text");
74 await using screen = await render(
75 <div className="flex h-[400px] flex-col">
76 <ReadOnlySourcePreview name="notes.txt" text={contents} wordWrap={false} />
77 </div>,
78 );
79 const root = screen.container.querySelector("diffs-container")!.shadowRoot!;
80 const rows = () => root.querySelectorAll("[data-line]");
81 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
82
83 await userEvent.click(rows()[0]!);
84 await userEvent.keyboard("{ControlOrMeta>}a{/ControlOrMeta}");
85 const scroller = screen.container.querySelector(".file-preview-virtualizer")!;
86 scroller.scrollTop = scroller.scrollHeight;
87 await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"));
88 expect(copiedText(rows()[0]!)).toBe(contents);
89 });
90
91 it("copies a partial selection after its rows scroll out of the mounted window", async () => {
92 await getSyntaxHighlighterPromise("text");
93 await using screen = await render(
94 <div className="flex h-[400px] flex-col">
95 <ReadOnlySourcePreview name="notes.txt" text={text} wordWrap={false} />
96 </div>,
97 );
98 const root = screen.container.querySelector("diffs-container")!.shadowRoot!;
99 const rows = () => root.querySelectorAll("[data-line]");
100 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
101
102 const rowText = (line: number) =>
103 [...rows()].find((row) => row.textContent === `line ${line}`)!.querySelector("span")!
104 .firstChild!;
105 document.getSelection()!.setBaseAndExtent(rowText(5), 0, rowText(20), "line 20".length);
106 const selected = Array.from({ length: 16 }, (_, index) => `line ${index + 5}`).join("\n");
107
108 const scroller = screen.container.querySelector(".file-preview-virtualizer")!;
109 scroller.scrollTop = scroller.scrollHeight;
110 await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"));
111 scroller.scrollTop = 0;
112 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
113
114 expect(copiedText(rows()[0]!) ?? document.getSelection()!.toString()).toBe(selected);
115 });
116
117 it("drops a selection scrolled out of the mounted window when a click lands on its edge", async () => {
118 await getSyntaxHighlighterPromise("text");
119 await using screen = await render(
120 <div className="flex h-[400px] flex-col">
121 <ReadOnlySourcePreview name="notes.txt" text={text} wordWrap={false} />
122 </div>,
123 );
124 const root = screen.container.querySelector("diffs-container")!.shadowRoot!;
125 const rows = () => root.querySelectorAll("[data-line]");
126 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
127
128 const rowText = (index: number) => rows()[index]!.querySelector("span")!.firstChild!;
129 document.getSelection()!.setBaseAndExtent(rowText(4), 0, rowText(19), 4);
130 const scroller = screen.container.querySelector(".file-preview-virtualizer")!;
131 scroller.scrollTop = scroller.scrollHeight;
132 await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"));
133 expect(copiedText(rows()[0]!)).toContain("line 5\n");
134
135 // The first mounted row is where the scrolled-out selection is parked, far above the viewport.
136 rows()[0]!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, composed: true }));
137 document.getSelection()!.collapse(rowText(0), 0);
138 expect(copiedText(rows()[0]!)).toBeNull();
139 });
140
141 it("copies a drag that autoscrolls past the edge of the preview", async () => {
142 await getSyntaxHighlighterPromise("text");
143 await using screen = await render(
144 <div className="flex h-[400px] flex-col">
145 <ReadOnlySourcePreview name="notes.txt" text={text} wordWrap={false} />
146 </div>,
147 );
148 const root = screen.container.querySelector("diffs-container")!.shadowRoot!;
149 const rows = () => root.querySelectorAll("[data-line]");
150 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
151
152 const start = rows()[4]!.querySelector("span")!.getBoundingClientRect();
153 const scroller = screen.container.querySelector(".file-preview-virtualizer")!;
154 const edge = scroller.getBoundingClientRect().bottom;
155 const frame = window.frameElement?.getBoundingClientRect();
156 const mouse = (type: "mousePressed" | "mouseMoved" | "mouseReleased", x: number, y: number) =>
157 cdp().send("Input.dispatchMouseEvent", {
158 type,
159 x: x + (frame?.left ?? 0),
160 y: y + (frame?.top ?? 0),
161 button: "left",
162 buttons: type === "mouseReleased" ? 0 : 1,
163 clickCount: 1,
164 });
165 await mouse("mousePressed", start.left + 1, start.top + 5);
166 await mouse("mouseMoved", start.left + 30, edge - 5);
167 // Held below the preview, the pointer is over no row while the browser scrolls the drag on.
168 await mouse("mouseMoved", start.left + 30, edge + 60);
169 await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"), { timeout: 10_000 });
170 await mouse("mouseReleased", start.left + 30, edge + 60);
171
172 const copied = copiedText(rows()[0]!)!;
173 const from = text.indexOf("line 5\n");
174 expect(copied).toBe(text.slice(from, from + copied.length));
175 // The drag ends in the rows on screen, short of the last mounted one.
176 const lastLine = 4 + copied.split("\n").length;
177 expect(lastLine).toBeGreaterThan(50);
178 expect(lastLine).toBeLessThan(Number(rows()[rows().length - 1]!.getAttribute("data-line")));
179 });
180
181 it("extends a selection with Shift+click after its anchor scrolls out of the mounted window", async () => {
182 await getSyntaxHighlighterPromise("text");
183 await using screen = await render(
184 <div className="flex h-[400px] flex-col">
185 <ReadOnlySourcePreview name="notes.txt" text={text} wordWrap={false} />
186 </div>,
187 );
188 const root = screen.container.querySelector("diffs-container")!.shadowRoot!;
189 const rows = () => root.querySelectorAll("[data-line]");
190 await vi.waitFor(() => expect(rows()[0]?.textContent).toBe("line 1"));
191
192 await userEvent.click(rows()[2]!.querySelector("span")!, { position: { x: 1, y: 5 } });
193 const scroller = screen.container.querySelector(".file-preview-virtualizer")!;
194 scroller.scrollTop = scroller.scrollHeight;
195 await vi.waitFor(() => expect(rows()[0]?.textContent).not.toBe("line 1"));
196 await userEvent.click(rows()[rows().length - 1]!.querySelector("span")!, {
197 position: { x: 1, y: 5 },
198 modifiers: ["Shift"],
199 });
200
201 const clicked = document.getSelection()!.getComposedRanges({ shadowRoots: [root] })[0]!;
202 const lastLine = clicked.endContainer.textContent!;
203 expect(Number(lastLine.replace("line ", ""))).toBeGreaterThan(250);
204 expect(copiedText(rows()[0]!)).toBe(
205 text.slice(text.indexOf("line 3\n"), text.indexOf(`${lastLine}\n`)),
206 );
207 });
208});