From 5767a0fd59f2e53dff88e71d94038a8e69fbbb0e Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 20 Aug 2026 14:26:39 +0000 Subject: [PATCH] fix(snapshot): preserve previews when syntax highlighting fails Closes #6777 --- .../__tests__/codeSnapshot.spec.ts | 59 ++++++++++++++++--- .../src/lib/codeSnapshot/codeSnapshot.ts | 14 ++++- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts index 5bb884f4cd..6ca204e986 100644 --- a/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts +++ b/apps/desktop/src/lib/codeSnapshot/__tests__/codeSnapshot.spec.ts @@ -1,15 +1,20 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { CODE_SNAPSHOT_CSS, renderCodeSnapshotHtml, savePngDataUrlToFile, snapshotElementToPng } from "@/lib/codeSnapshot/codeSnapshot"; -const { toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => ({ - toPng: vi.fn(), - isTauriRuntime: vi.fn(), - save: vi.fn(), - writeFile: vi.fn(), -})); +const { createCodeHighlighter, highlightCode, toPng, isTauriRuntime, save, writeFile } = vi.hoisted(() => { + const highlightCode = vi.fn((content: string, _lang: string) => `${content}`); + return { + createCodeHighlighter: vi.fn(async () => highlightCode), + highlightCode, + toPng: vi.fn(), + isTauriRuntime: vi.fn(), + save: vi.fn(), + writeFile: vi.fn(), + }; +}); vi.mock("@/lib/ai/aiCodeHighlighter", () => ({ - createAiShikiCodeHighlighter: vi.fn().mockResolvedValue((content: string, _lang: string) => `${content}`), + createAiShikiCodeHighlighter: createCodeHighlighter, })); vi.mock("dom-to-image-more", () => ({ @@ -23,7 +28,45 @@ vi.mock("@/lib/backend/tauriRuntime", () => ({ vi.mock("@tauri-apps/plugin-dialog", () => ({ save })); vi.mock("@tauri-apps/plugin-fs", () => ({ writeFile })); +beforeEach(() => { + createCodeHighlighter.mockResolvedValue(highlightCode); + highlightCode.mockClear(); + highlightCode.mockImplementation((content: string) => `${content}`); +}); + describe("renderCodeSnapshotHtml", () => { + it("falls back to safe text when the code highlighter cannot initialize", async () => { + createCodeHighlighter.mockRejectedValueOnce(new SyntaxError("Invalid regular expression: invalid group specifier name")); + + const html = await renderCodeSnapshotHtml({ code: "SELECT <初始化失败>", lang: "sql" }, { appearance: "light" }); + + expect(html).toContain('SELECT <初始化失败>'); + }); + + it("falls back to safe line-preserving text when legacy WebKit cannot compile highlighting regexes", async () => { + highlightCode.mockImplementationOnce(() => { + throw new SyntaxError("Invalid regular expression: invalid group specifier name"); + }); + + const html = await renderCodeSnapshotHtml({ code: `SELECT '<用户>&" 😀'\r\n-- 第二行\n`, lang: "sql" }, { appearance: "light" }); + + expect(html).toContain('SELECT '<用户>&" 😀'-- 第二行'); + expect(html).not.toContain("<用户>"); + expect(html.match(/class="line"/g)).toHaveLength(3); + }); + + it("renders large fallback selections with one highlighter attempt", async () => { + const code = Array.from({ length: 5_000 }, (_, index) => `SELECT ${index}`).join("\n"); + highlightCode.mockImplementationOnce(() => { + throw new SyntaxError("Invalid regular expression: invalid group specifier name"); + }); + + const html = await renderCodeSnapshotHtml({ code, lang: "sql" }, { appearance: "dark" }); + + expect(highlightCode).toHaveBeenCalledOnce(); + expect(html.match(/class="line"/g)).toHaveLength(5_000); + }); + it("renders a self-contained snapshot with embedded styles", async () => { const html = await renderCodeSnapshotHtml({ code: "SELECT 1", lang: "sql" }, { appearance: "dark" }); diff --git a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts index 89cf5414c1..8f44809db8 100644 --- a/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts +++ b/apps/desktop/src/lib/codeSnapshot/codeSnapshot.ts @@ -150,6 +150,10 @@ function escapeHtml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } +function renderEscapedSnapshotLines(value: string): string { + return `${escapeHtml(value).replace(/\r\n|\r|\n/g, '')}`; +} + function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppearance, showTrafficLights: boolean): string { const dots = showTrafficLights ? TRAFFIC_LIGHT_COLORS.map((color) => ``).join("") : ""; const titleHtml = title ? `${escapeHtml(title)}` : ""; @@ -162,8 +166,14 @@ function renderSnapshotBar(title: string | undefined, appearance: AppThemeAppear * `v-html`, then exported with `snapshotElementToPng`. */ export async function renderCodeSnapshotHtml(source: CodeSnapshotSource, options: CodeSnapshotStyleOptions): Promise { - const highlight = await getCodeSnapshotHighlighter(); - const body = highlight(source.code, source.lang, options.appearance); + let body: string; + try { + const highlight = await getCodeSnapshotHighlighter(); + body = highlight(source.code, source.lang, options.appearance); + } catch { + // Legacy WebKit can reject regular expressions generated by Shiki. + body = renderEscapedSnapshotLines(source.code); + } const appearance = options.appearance; const showBar = options.showTrafficLights !== false || !!source.title;