返回 DeepSeek-Reasonix
line-number-code.test.tsx
根目录 / desktop / frontend / src / __tests__ / line-number-code.test.tsx
1 // Run: tsx src/__tests__/line-number-code.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { CodeViewer } from "../components/CodeViewer";
6 import LineNumberCode, {
7 findCodeMatches,
8 highlightLineMatches,
9 MAX_SEARCH_MATCHES,
10 splitHighlightedCodeLines,
11 } from "../components/editors/LineNumberCode";
12 import {
13 findRegexCodeMatches,
14 MAX_REGEX_PATTERN_LENGTH,
15 MAX_REGEX_SOURCE_LENGTH,
16 type RegexSearchRequest,
17 type RegexSearchResponse,
18 } from "../components/editors/codeSearch";
19 import {
20 startRegexSearch,
21 type RegexSearchWorker,
22 } from "../components/editors/regexSearchClient";
23 import {
24 highlightToHtml,
25 MAX_HIGHLIGHT_BYTES,
26 MAX_HIGHLIGHT_LINES,
27 shouldHighlightCode,
28 shouldHighlightSource,
29 } from "../lib/highlight";
30 import { LocaleProvider } from "../lib/i18n";
31
32 let passed = 0;
33 let failed = 0;
34
35 function ok(value: unknown, label: string) {
36 if (value) {
37 process.stdout.write(` PASS ${label}\n`);
38 passed += 1;
39 } else {
40 process.stdout.write(` FAIL ${label}\n`);
41 failed += 1;
42 }
43 }
44
45 function flush(): Promise<void> {
46 return new Promise((resolve) => setTimeout(resolve, 0));
47 }
48
49 function wait(ms: number): Promise<void> {
50 return new Promise((resolve) => setTimeout(resolve, ms));
51 }
52
53 async function waitForSelector(container: ParentNode, selector: string, timeoutMs = 1_000): Promise<Element> {
54 const deadline = Date.now() + timeoutMs;
55 while (Date.now() < deadline) {
56 const element = container.querySelector(selector);
57 if (element) return element;
58 await act(async () => wait(10));
59 }
60 throw new Error(`Timed out waiting for ${selector}`);
61 }
62
63 console.log("\nline-number code viewer");
64
65 const repeatedMatches = findCodeMatches("x\nx\nx", "x").matches;
66 ok(repeatedMatches.length === 3, "finds matches on consecutive lines");
67 ok(
68 repeatedMatches.map((match) => match.lineIndex).join(",") === "0,1,2",
69 "does not carry regex state between lines",
70 );
71 ok(findCodeMatches("x x", "x").matches.length === 2, "counts occurrences rather than matching lines");
72 ok(
73 findCodeMatches("猫 猫咪 猫", "猫", false, true).matches.length === 2,
74 "whole-word matching respects Unicode word characters",
75 );
76 ok(
77 findCodeMatches("a+b aab a+b", "a+b").matches.length === 2,
78 "treats regex metacharacters as literal search text",
79 );
80
81 const regexRequest = (overrides: Partial<RegexSearchRequest> = {}): RegexSearchRequest => ({
82 requestId: 1,
83 source: "foo1 foo22\nbar3",
84 pattern: String.raw`foo\d+`,
85 caseSensitive: false,
86 wholeWord: false,
87 maxMatches: MAX_SEARCH_MATCHES,
88 ...overrides,
89 });
90 const regexMatches = findRegexCodeMatches(regexRequest());
91 ok(regexMatches.ok && regexMatches.result.matches.length === 2, "regex mode evaluates explicit patterns");
92 ok(
93 regexMatches.ok && regexMatches.result.matches.map((match) => match.start).join(",") === "0,5",
94 "regex mode returns JavaScript-compatible UTF-16 offsets",
95 );
96 const lineAnchoredRegex = findRegexCodeMatches(regexRequest({
97 source: "foo\nfoo",
98 pattern: "^foo",
99 }));
100 ok(
101 lineAnchoredRegex.ok
102 && lineAnchoredRegex.result.matches.length === 2
103 && lineAnchoredRegex.result.matches.map((match) => match.lineIndex).join(",") === "0,1",
104 "regex anchors continue to address individual source lines",
105 );
106 const multilineRegex = findRegexCodeMatches(regexRequest({
107 source: "foo\nbar",
108 pattern: String.raw`foo\nbar`,
109 }));
110 ok(!multilineRegex.ok && multilineRegex.error === "multiline_unsupported", "reports unsupported multiline regex matches");
111 const regexLiteralDifference = findRegexCodeMatches(regexRequest({
112 source: "a+b aab aaab",
113 pattern: "a+b",
114 }));
115 ok(
116 regexLiteralDifference.ok && regexLiteralDifference.result.matches.length === 2,
117 "regex mode keeps metacharacter semantics separate from literal search",
118 );
119 const invalidRegex = findRegexCodeMatches(regexRequest({ pattern: "(" }));
120 ok(!invalidRegex.ok && invalidRegex.error === "invalid_pattern", "reports invalid regular expressions");
121 const zeroLengthRegex = findRegexCodeMatches(regexRequest({ pattern: "^" }));
122 ok(
123 !zeroLengthRegex.ok && zeroLengthRegex.error === "zero_length_unsupported",
124 "rejects zero-length regex matches that cannot be highlighted safely",
125 );
126 const mixedLengthRegex = findRegexCodeMatches(regexRequest({
127 source: "abc",
128 pattern: ".*",
129 }));
130 ok(
131 mixedLengthRegex.ok
132 && mixedLengthRegex.result.matches.length === 1
133 && mixedLengthRegex.result.matches[0].start === 0
134 && mixedLengthRegex.result.matches[0].end === 3,
135 "keeps non-empty regex results when the expression also produces an empty match",
136 );
137 const longRegex = findRegexCodeMatches(regexRequest({ pattern: "x".repeat(MAX_REGEX_PATTERN_LENGTH + 1) }));
138 ok(!longRegex.ok && longRegex.error === "pattern_too_long", "caps regular-expression length");
139 const oversizedRegexSource = findRegexCodeMatches(regexRequest({
140 source: "x".repeat(MAX_REGEX_SOURCE_LENGTH + 1),
141 }));
142 ok(
143 !oversizedRegexSource.ok && oversizedRegexSource.error === "source_too_large",
144 "caps source copied into the regex worker",
145 );
146 const cappedRegex = findRegexCodeMatches(regexRequest({
147 source: "x x x",
148 pattern: "x",
149 maxMatches: 2,
150 }));
151 ok(
152 cappedRegex.ok && cappedRegex.result.matches.length === 2 && cappedRegex.result.truncated,
153 "caps regex result sets before returning them to the UI",
154 );
155
156 class FakeRegexWorker implements RegexSearchWorker {
157 onmessage: ((event: MessageEvent<RegexSearchResponse>) => void) | null = null;
158 onerror: ((event: ErrorEvent) => void) | null = null;
159 posted: RegexSearchRequest[] = [];
160 terminated = false;
161
162 postMessage(request: RegexSearchRequest) {
163 this.posted.push(request);
164 }
165
166 terminate() {
167 this.terminated = true;
168 }
169
170 respond(response: RegexSearchResponse) {
171 this.onmessage?.({ data: response } as MessageEvent<RegexSearchResponse>);
172 }
173 }
174
175 const timedOutWorker = new FakeRegexWorker();
176 let timeoutCallback: (() => void) | null = null;
177 let timeoutResponse: RegexSearchResponse | null = null;
178 startRegexSearch(
179 regexRequest({ requestId: 2 }),
180 { onResponse: (response) => { timeoutResponse = response; } },
181 {
182 createWorker: async () => timedOutWorker,
183 setTimer: (callback) => {
184 timeoutCallback = callback;
185 return 1;
186 },
187 clearTimer: () => {},
188 },
189 );
190 await Promise.resolve();
191 timeoutCallback?.();
192 ok(timedOutWorker.terminated, "hard timeout terminates a stuck regex worker");
193 ok(
194 timeoutResponse != null && !timeoutResponse.ok && timeoutResponse.error === "timeout",
195 "hard timeout reports a bounded search failure",
196 );
197
198 let creationTimeoutCallback: (() => void) | null = null;
199 let creationTimeoutResponse: RegexSearchResponse | null = null;
200 startRegexSearch(
201 regexRequest({ requestId: 5 }),
202 { onResponse: (response) => { creationTimeoutResponse = response; } },
203 {
204 createWorker: () => new Promise<RegexSearchWorker>(() => {}),
205 setTimer: (callback) => {
206 creationTimeoutCallback = callback;
207 return 5;
208 },
209 clearTimer: () => {},
210 },
211 );
212 creationTimeoutCallback?.();
213 ok(
214 creationTimeoutResponse != null
215 && !creationTimeoutResponse.ok
216 && creationTimeoutResponse.error === "timeout",
217 "hard timeout covers a worker that never finishes initializing",
218 );
219
220 const staleWorker = new FakeRegexWorker();
221 let staleResponseAccepted = false;
222 const cancelStaleSearch = startRegexSearch(
223 regexRequest({ requestId: 3 }),
224 { onResponse: () => { staleResponseAccepted = true; } },
225 {
226 createWorker: async () => staleWorker,
227 setTimer: () => 2,
228 clearTimer: () => {},
229 },
230 );
231 await Promise.resolve();
232 cancelStaleSearch();
233 staleWorker.respond({
234 requestId: 3,
235 ok: true,
236 result: { matches: [], truncated: false },
237 });
238 ok(staleWorker.terminated, "cancelling a stale request terminates its worker");
239 ok(!staleResponseAccepted, "discarded requests cannot update search results");
240
241 const completedWorker = new FakeRegexWorker();
242 let completedResponse: RegexSearchResponse | null = null;
243 let completedTimerCleared = false;
244 startRegexSearch(
245 regexRequest({ requestId: 4 }),
246 { onResponse: (response) => { completedResponse = response; } },
247 {
248 createWorker: async () => completedWorker,
249 setTimer: () => 3,
250 clearTimer: () => { completedTimerCleared = true; },
251 },
252 );
253 await Promise.resolve();
254 completedWorker.respond({
255 requestId: 4,
256 ok: true,
257 result: { matches: [], truncated: false },
258 });
259 ok(completedResponse?.requestId === 4, "accepts the current worker response");
260 ok(completedWorker.terminated && completedTimerCleared, "successful searches release worker and timer resources");
261
262 const cappedMatches = findCodeMatches("x".repeat(MAX_SEARCH_MATCHES + 1), "x");
263 ok(cappedMatches.matches.length === MAX_SEARCH_MATCHES, "caps pathological result sets");
264 ok(cappedMatches.truncated, "reports capped result sets to the viewer");
265 ok(shouldHighlightCode(MAX_HIGHLIGHT_BYTES, MAX_HIGHLIGHT_LINES), "keeps syntax highlighting within its budget");
266 ok(!shouldHighlightCode(MAX_HIGHLIGHT_BYTES + 1, 1), "falls back to plain text above the byte budget");
267 ok(!shouldHighlightCode(1, MAX_HIGHLIGHT_LINES + 1), "falls back to plain text above the line budget");
268 ok(shouldHighlightSource("const value = 1;"), "keeps ordinary chat code within the shared budget");
269 ok(
270 !shouldHighlightSource("é".repeat(Math.floor(MAX_HIGHLIGHT_BYTES / 2) + 1)),
271 "measures the shared byte budget as UTF-8 rather than UTF-16 code units",
272 );
273 ok(
274 !shouldHighlightSource("const value = 1;\n".repeat(MAX_HIGHLIGHT_LINES)),
275 "applies the shared line budget without requiring a workspace preview",
276 );
277
278 const entitySource = "const x = \"<&\";";
279 for (const query of ["<", "&"]) {
280 const matches = findCodeMatches(entitySource, query).matches;
281 const markedHtml = highlightLineMatches(
282 splitHighlightedCodeLines(highlightToHtml(entitySource, "typescript"))[0],
283 matches,
284 matches[0],
285 );
286 const entityDom = new JSDOM(`<code>${markedHtml}</code>`);
287 const code = entityDom.window.document.querySelector("code");
288 ok(code?.textContent === entitySource, `searching ${query} preserves rendered source text`);
289 ok(code?.querySelectorAll("mark").length === 1, `searching ${query} highlights the exact entity`);
290 }
291
292 const multilineSource = "const value = `first\nsecond`;";
293 const multilineHtml = splitHighlightedCodeLines(highlightToHtml(multilineSource, "typescript"));
294 ok(multilineHtml.length === 2, "splits highlighted multiline source into rows");
295 ok(multilineHtml[1].includes("hljs-string"), "preserves lexer state on the second line");
296
297 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
298 pretendToBeVisual: true,
299 url: "http://localhost/",
300 });
301 (dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
302 (dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
303 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
304 globalThis.window = dom.window as unknown as Window & typeof globalThis;
305 globalThis.document = dom.window.document;
306 globalThis.Node = dom.window.Node;
307 globalThis.HTMLElement = dom.window.HTMLElement;
308 globalThis.HTMLInputElement = dom.window.HTMLInputElement;
309 globalThis.Event = dom.window.Event;
310 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
311 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
312 const { createRoot } = await import("react-dom/client");
313
314 const scrolledLines: number[] = [];
315 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
316 configurable: true,
317 value(this: HTMLElement) {
318 const lineIndex = this.dataset.lineIndex;
319 if (lineIndex != null) scrolledLines.push(Number(lineIndex));
320 },
321 });
322 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTo", {
323 configurable: true,
324 value() {},
325 });
326
327 const container = document.getElementById("root")!;
328 const root = createRoot(container);
329 const searchableValue = Array.from(
330 { length: 80 },
331 (_, index) => index === 39 || index === 59 ? `needle ${index + 1}` : `line ${index + 1}`,
332 ).join("\n");
333 await act(async () => {
334 root.render(
335 <LocaleProvider>
336 <LineNumberCode value={searchableValue} showLineNumbers />
337 <LineNumberCode value="beta" showLineNumbers />
338 </LocaleProvider>,
339 );
340 });
341
342 ok(container.querySelectorAll(".code-block__copy").length === 2, "keeps copy controls on line-number viewers");
343 const viewers = container.querySelectorAll<HTMLElement>(".code--lines");
344 await act(async () => {
345 viewers[0].focus();
346 viewers[0].dispatchEvent(new KeyboardEvent("keydown", { key: "f", ctrlKey: true, bubbles: true }));
347 await flush();
348 });
349 ok(container.querySelectorAll(".code-search").length === 1, "opens search only for the focused viewer");
350 ok(
351 container.querySelectorAll(".code-block__wrap")[0].querySelector(".code-search") != null,
352 "keeps the search shortcut scoped to its owning viewer",
353 );
354 ok(
355 container.querySelectorAll(".code-block__wrap")[1].querySelector(".code-search") == null,
356 "does not fan the shortcut out to sibling viewers",
357 );
358 const firstViewer = container.querySelectorAll<HTMLElement>(".code-block__wrap")[0];
359 ok(
360 firstViewer.querySelector(".code-block__copy") == null,
361 "removes the covered floating copy control while search is open",
362 );
363 ok(
364 firstViewer.querySelector('.code-search .code-search__copy[aria-label="Copy"]') != null,
365 "keeps copy available as a visible search-toolbar action",
366 );
367
368 const pendingRequestContainer = document.createElement("div");
369 document.body.appendChild(pendingRequestContainer);
370 const pendingRequestRoot = createRoot(pendingRequestContainer);
371 let pendingRequestConsumed = false;
372 await act(async () => {
373 pendingRequestRoot.render(
374 <LocaleProvider>
375 <LineNumberCode
376 value="pending search"
377 showLineNumbers
378 searchRequestPending
379 onSearchRequestConsumed={() => {
380 pendingRequestConsumed = true;
381 }}
382 />
383 </LocaleProvider>,
384 );
385 await flush();
386 });
387 ok(
388 pendingRequestContainer.querySelector(".code-search") != null,
389 "consumes a search request that arrived before the editor mounted",
390 );
391 ok(pendingRequestConsumed, "acknowledges a consumed search request");
392 await act(async () => pendingRequestRoot.unmount());
393
394 const searchInput = container.querySelector<HTMLInputElement>(".code-search__input")!;
395 await act(async () => {
396 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
397 setter?.call(searchInput, "needle");
398 searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
399 searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
400 await wait(130);
401 });
402 await act(async () => flush());
403 ok(container.querySelector(".code-search__count")?.textContent === "1 of 2", "activates the first match after a query settles");
404 ok(scrolledLines.at(-1) === 39, "reveals the first match instead of leaving the viewport behind");
405 ok(container.querySelector(".code-line-row--current .code-line-ln")?.textContent === "40", "marks the first matching line as current");
406
407 await act(async () => {
408 searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
409 await flush();
410 });
411 ok(container.querySelector(".code-search__count")?.textContent === "2 of 2", "Enter advances from the visible first match");
412 ok(scrolledLines.at(-1) === 59, "Enter reveals the next matching line");
413
414 await act(async () => {
415 searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true }));
416 await flush();
417 });
418 ok(container.querySelector(".code-search__count")?.textContent === "1 of 2", "Shift+Enter navigates to the previous match");
419
420 await act(async () => {
421 searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "f", ctrlKey: true, bubbles: true }));
422 await flush();
423 });
424 ok(document.activeElement === searchInput, "repeated find shortcuts stay scoped while the search input is focused");
425
426 await act(async () => {
427 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
428 setter?.call(searchInput, "line");
429 searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
430 searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
431 await flush();
432 searchInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
433 await flush();
434 });
435 await act(async () => flush());
436 ok(container.querySelector(".code-search__count")?.textContent === "1 of 78", "immediate Enter commits a pending query at its first match");
437 ok(scrolledLines.at(-1) === 0, "immediate Enter reveals the first result instead of skipping ahead");
438
439 const caseToggle = container.querySelector<HTMLButtonElement>('[aria-label="Match case"]')!;
440 ok(caseToggle.getAttribute("aria-pressed") === "false", "exposes the inactive match-case state");
441 await act(async () => {
442 caseToggle.click();
443 await flush();
444 });
445 ok(caseToggle.getAttribute("aria-pressed") === "true", "exposes the active match-case state");
446
447 await act(async () => {
448 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
449 setter?.call(searchInput, "");
450 searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
451 searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
452 await wait(130);
453 });
454 const regexToggle = container.querySelector<HTMLButtonElement>('[aria-label="Use regular expression"]')!;
455 ok(regexToggle.getAttribute("aria-pressed") === "false", "keeps regex mode disabled by default");
456 await act(async () => {
457 regexToggle.click();
458 await flush();
459 });
460 ok(regexToggle.getAttribute("aria-pressed") === "true", "exposes regex mode as an explicit advanced toggle");
461 await act(async () => {
462 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
463 setter?.call(searchInput, "x".repeat(MAX_REGEX_PATTERN_LENGTH + 1));
464 searchInput.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
465 searchInput.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
466 await wait(130);
467 });
468 ok(
469 container.querySelector(".code-search__count--error")?.textContent === "Expression too long",
470 "shows a localized error before unsafe regex work starts",
471 );
472
473 await act(async () => {
474 container.querySelector<HTMLButtonElement>('[aria-label="Close search"]')?.click();
475 await flush();
476 });
477 ok(firstViewer.querySelector(".code-search") == null, "closes the viewer-scoped search toolbar");
478 ok(firstViewer.querySelector(".code-block__copy") != null, "restores the floating copy control after search closes");
479
480 await act(async () => root.unmount());
481
482 const largeContainer = document.createElement("div");
483 document.body.appendChild(largeContainer);
484 const largeRoot = createRoot(largeContainer);
485 await act(async () => {
486 largeRoot.render(
487 <LocaleProvider>
488 <LineNumberCode
489 value={'const text = "<&";'}
490 language="typescript"
491 sourceSize={MAX_HIGHLIGHT_BYTES + 1}
492 showLineNumbers
493 />
494 </LocaleProvider>,
495 );
496 });
497 ok(largeContainer.querySelector(".code--lines")?.getAttribute("data-highlight-mode") === "plain", "uses the plain-text path above the syntax budget");
498 ok(largeContainer.querySelector(".code-line-text")?.textContent === 'const text = "<&";', "plain-text fallback preserves escaped source text");
499 ok(largeContainer.querySelector(".hljs-keyword") == null, "plain-text fallback skips syntax token markup");
500 await act(async () => largeRoot.unmount());
501
502 const defaultContainer = document.createElement("div");
503 document.body.appendChild(defaultContainer);
504 const defaultRoot = createRoot(defaultContainer);
505 await act(async () => {
506 defaultRoot.render(
507 <LocaleProvider>
508 <CodeViewer value="const unchanged = true;" language="typescript" />
509 </LocaleProvider>,
510 );
511 });
512 // React.lazy may resolve after more than one task under React 19. Wait for the
513 // real viewer instead of asserting against the transient Suspense fallback.
514 await waitForSelector(defaultContainer, "pre.code.hljs");
515 ok(defaultContainer.querySelector("pre.code.hljs") != null, "keeps the established viewer as the default seam");
516 ok(defaultContainer.querySelector(".code--lines") == null, "requires an explicit line-number opt-in");
517 ok(defaultContainer.querySelector(".code-block__copy") != null, "keeps copy available on default code blocks");
518 await act(async () => defaultRoot.unmount());
519
520 const largeChatContainer = document.createElement("div");
521 document.body.appendChild(largeChatContainer);
522 const largeChatRoot = createRoot(largeChatContainer);
523 const largeChatValue = "const value = 1;\n".repeat(MAX_HIGHLIGHT_LINES);
524 await act(async () => {
525 largeChatRoot.render(
526 <LocaleProvider>
527 <CodeViewer value={largeChatValue} language="typescript" />
528 </LocaleProvider>,
529 );
530 });
531 await waitForSelector(largeChatContainer, 'pre[data-highlight-mode="plain"]');
532 ok(
533 largeChatContainer.querySelector('pre[data-highlight-mode="plain"]') != null,
534 "ordinary chat blocks reuse the shared syntax budget",
535 );
536 ok(
537 largeChatContainer.querySelector(".hljs-keyword") == null,
538 "oversized chat blocks skip syntax token generation",
539 );
540 ok(
541 largeChatContainer.querySelector("code")?.textContent === largeChatValue,
542 "oversized chat blocks preserve their plain-text content",
543 );
544 await act(async () => largeChatRoot.unmount());
545
546 console.log(`\n${passed} passed, ${failed} failed`);
547 if (failed > 0) process.exit(1);
548
548 lines Plain Text