1import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2
3import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface";
4import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes";
5import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter";
6
7const tokenization = vi.hoisted(() => ({
8 calls: [] as string[],
9 afterCall: undefined as (() => void) | undefined,
10}));
11
12vi.mock("@shikijs/core", async (importOriginal) => {
13 const original = await importOriginal<typeof import("@shikijs/core")>();
14 return {
15 ...original,
16 createHighlighterCore: async (...args: Parameters<typeof original.createHighlighterCore>) => {
17 const highlighter = await original.createHighlighterCore(...args);
18 return {
19 ...highlighter,
20 codeToTokensBase: (...input: Parameters<typeof highlighter.codeToTokensBase>) => {
21 tokenization.calls.push(input[0]);
22 const result = highlighter.codeToTokensBase(...input);
23 tokenization.afterCall?.();
24 return result;
25 },
26 };
27 },
28 };
29});
30
31// Exercise the native entry path without requiring an iOS or Android runtime.
32vi.mock("react-native-shiki-engine", async () => {
33 const { createJavaScriptRegexEngine } = await import("@shikijs/engine-javascript");
34 return { isNativeEngineAvailable: () => true, createNativeEngine: createJavaScriptRegexEngine };
35});
36
37afterEach(() => {
38 tokenization.calls = [];
39 tokenization.afterCall = undefined;
40});
41
42const TYPESCRIPT_FILE: NativeReviewDiffFile = {
43 id: "file-1",
44 path: "example.ts",
45 language: "typescript",
46 additions: 0,
47 deletions: 0,
48};
49
50function makeLine(
51 input: Pick<NativeReviewDiffRow, "id" | "content" | "change" | "oldLineNumber" | "newLineNumber">,
52): NativeReviewDiffRow {
53 return {
54 kind: "line",
55 fileId: TYPESCRIPT_FILE.id,
56 ...input,
57 };
58}
59
60function makeHunk(id: string): NativeReviewDiffRow {
61 return {
62 kind: "hunk",
63 id,
64 fileId: TYPESCRIPT_FILE.id,
65 text: "@@",
66 };
67}
68
69function highlight(
70 rows: ReadonlyArray<NativeReviewDiffRow>,
71 alreadyHighlightedRowIds?: ReadonlySet<string>,
72) {
73 return highlightNativeReviewDiffVisibleRows({
74 rows,
75 files: [TYPESCRIPT_FILE],
76 scheme: "dark",
77 engine: "javascript",
78 firstRowIndex: 0,
79 lastRowIndex: rows.length - 1,
80 overscanRows: 0,
81 maxRows: 100,
82 alreadyHighlightedRowIds,
83 });
84}
85
86describe("highlightNativeReviewDiffVisibleRows", () => {
87 it("does not carry grammar state across hunk boundaries", async () => {
88 const exportRow = makeLine({
89 id: "export-row",
90 content: "export async function run() {}",
91 change: "add",
92 oldLineNumber: null,
93 newLineNumber: 100,
94 });
95 const rows = [
96 makeHunk("hunk-1"),
97 makeLine({
98 id: "import-open",
99 content: "import {",
100 change: "context",
101 oldLineNumber: 1,
102 newLineNumber: 1,
103 }),
104 makeLine({
105 id: "import-entry",
106 content: " Model,",
107 change: "context",
108 oldLineNumber: 2,
109 newLineNumber: 2,
110 }),
111 makeHunk("hunk-2"),
112 exportRow,
113 ];
114
115 const [highlighted, standalone] = await Promise.all([
116 highlight(rows),
117 highlight([makeHunk("standalone-hunk"), exportRow]),
118 ]);
119
120 expect(highlighted.tokensByRowId[exportRow.id]).toEqual(standalone.tokensByRowId[exportRow.id]);
121 });
122
123 it("keeps grammar state across inline comment rows", async () => {
124 const openingRow = makeLine({
125 id: "template-open",
126 content: "const message = `open",
127 change: "add",
128 oldLineNumber: null,
129 newLineNumber: 1,
130 });
131 const closingRow = makeLine({
132 id: "template-close",
133 content: "closed`;",
134 change: "add",
135 oldLineNumber: null,
136 newLineNumber: 2,
137 });
138 const trailingRow = makeLine({
139 id: "trailing-row",
140 content: "export const answer = 42;",
141 change: "add",
142 oldLineNumber: null,
143 newLineNumber: 3,
144 });
145 const commentRow: NativeReviewDiffRow = {
146 kind: "comment",
147 id: "comment-1",
148 fileId: TYPESCRIPT_FILE.id,
149 commentText: "Review note",
150 };
151
152 const [withComment, contiguous] = await Promise.all([
153 highlight([openingRow, commentRow, closingRow, trailingRow]),
154 highlight([openingRow, closingRow, trailingRow]),
155 ]);
156
157 expect(withComment.tokensByRowId).toEqual(contiguous.tokensByRowId);
158 });
159
160 it("does not join unhighlighted rows across cached gaps", async () => {
161 const trailingRow = makeLine({
162 id: "trailing-row",
163 content: "export const answer = 42;",
164 change: "add",
165 oldLineNumber: null,
166 newLineNumber: 3,
167 });
168 const rows = [
169 makeLine({
170 id: "template-open",
171 content: "const message = `open",
172 change: "add",
173 oldLineNumber: null,
174 newLineNumber: 1,
175 }),
176 makeLine({
177 id: "template-close",
178 content: "closed`;",
179 change: "add",
180 oldLineNumber: null,
181 newLineNumber: 2,
182 }),
183 trailingRow,
184 ];
185
186 const [highlighted, standalone] = await Promise.all([
187 highlight(rows, new Set(["template-close"])),
188 highlight([trailingRow]),
189 ]);
190
191 expect(highlighted.tokensByRowId[trailingRow.id]).toEqual(
192 standalone.tokensByRowId[trailingRow.id],
193 );
194 });
195
196 it("keeps deletion grammar state out of addition rows", async () => {
197 const additionRow = makeLine({
198 id: "addition-row",
199 content: "export const answer = 42;",
200 change: "add",
201 oldLineNumber: null,
202 newLineNumber: 1,
203 });
204 const rows = [
205 makeLine({
206 id: "deletion-row",
207 content: "const removed = `open",
208 change: "delete",
209 oldLineNumber: 1,
210 newLineNumber: null,
211 }),
212 additionRow,
213 ];
214
215 const [highlighted, standalone] = await Promise.all([
216 highlight(rows),
217 highlight([additionRow]),
218 ]);
219
220 expect(highlighted.tokensByRowId[additionRow.id]).toEqual(
221 standalone.tokensByRowId[additionRow.id],
222 );
223 });
224});
225
226describe.each(["native", "javascript"] as const)("%s highlighting budgets", (engine) => {
227 const highlightRows = (rows: ReadonlyArray<NativeReviewDiffRow>, signal?: AbortSignal) =>
228 highlightNativeReviewDiffVisibleRows({
229 rows,
230 files: [TYPESCRIPT_FILE],
231 scheme: "dark",
232 engine,
233 firstRowIndex: 0,
234 lastRowIndex: rows.length - 1,
235 overscanRows: 0,
236 signal,
237 });
238
239 const line = (id: number, content: string) =>
240 makeLine({
241 id: `line-${id}`,
242 content,
243 change: "add",
244 oldLineNumber: null,
245 newLineNumber: id,
246 });
247
248 it("still highlights a line at the length limit", async () => {
249 const content = `// ${"x".repeat(997)}`;
250 const result = await highlightRows([line(1, content)]);
251
252 expect(tokenization.calls).toEqual([content]);
253 expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true);
254 });
255
256 it("keeps only the long line plain and resumes highlighting after it", async () => {
257 const longLine = `${"x".repeat(1_001)} /*`;
258 const rows = [
259 line(1, "export const before = 1;"),
260 line(2, longLine),
261 { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id },
262 line(3, "export const inside = 'x';"),
263 makeHunk("next-hunk"),
264 line(100, "export const after = 2;"),
265 ] satisfies ReadonlyArray<NativeReviewDiffRow>;
266 const result = await highlightRows(rows);
267
268 expect(result.engine).toBe(engine);
269 expect(Object.keys(result.tokensByRowId)).toEqual(["line-1", "line-2", "line-3", "line-100"]);
270 expect(result.tokensByRowId["line-2"]).toEqual([
271 { content: longLine, color: null, fontStyle: null },
272 ]);
273 for (const id of ["line-1", "line-3", "line-100"]) {
274 expect(result.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true);
275 }
276 expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false);
277 });
278
279 it("highlights the rows after a long line the same regardless of the first window", async () => {
280 const rows = [
281 line(1, "export const before = 1;"),
282 line(2, `const data = "${"x".repeat(1_050)}";`),
283 line(3, "export const inside = 'x';"),
284 line(4, "export const after = 2;"),
285 ];
286 const spanning = await highlightRows(rows);
287 const afterLongLine = await highlightNativeReviewDiffVisibleRows({
288 rows,
289 files: [TYPESCRIPT_FILE],
290 scheme: "dark",
291 engine,
292 firstRowIndex: 2,
293 lastRowIndex: 3,
294 overscanRows: 0,
295 });
296
297 for (const id of ["line-3", "line-4"]) {
298 expect(spanning.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true);
299 expect(afterLongLine.tokensByRowId[id]).toEqual(spanning.tokensByRowId[id]);
300 }
301 });
302
303 it("preserves multiline grammar and row mapping across character-limited batches", async () => {
304 const opening = line(1, "const message = `open");
305 const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45)));
306 const closing = line(42, "closed`; ");
307 const trailing = line(43, "export const after = 2;");
308 const result = await highlightRows([opening, ...body, closing, trailing]);
309 const calls = [...tokenization.calls];
310 const expected = await highlightRows([
311 opening,
312 { ...closing, newLineNumber: 2 },
313 { ...trailing, newLineNumber: 3 },
314 ]);
315
316 expect(calls.length).toBeGreaterThan(1);
317 expect(calls.every((code) => code.length <= 8_000)).toBe(true);
318 expect(result.rowCount).toBe(43);
319 for (const row of [opening, ...body, closing, trailing]) {
320 expect(result.tokensByRowId[row.id]?.map((token) => token.content).join("")).toBe(
321 row.content,
322 );
323 }
324 expect(result.tokensByRowId[closing.id]).toEqual(expected.tokensByRowId[closing.id]);
325 expect(result.tokensByRowId[trailing.id]).toEqual(expected.tokensByRowId[trailing.id]);
326 });
327
328 it("stops before later batches and publishes no partial result after cancellation", async () => {
329 const controller = new AbortController();
330 tokenization.afterCall = () => controller.abort();
331 const rows = Array.from({ length: 30 }, (_, index) => line(index + 1, `// ${"x".repeat(400)}`));
332
333 const result = await highlightRows(rows, controller.signal);
334
335 expect(tokenization.calls).toHaveLength(1);
336 expect(result.rowCount).toBe(0);
337 expect(result.tokensByRowId).toEqual({});
338 });
339});