From 81eab1179bcf5cb7a84fc3b7fd139688bab7fd54 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 20 Apr 2025 22:43:29 +0800 Subject: [PATCH] test: add vitest (#5085) * test: migrate to vitest * test: update vitest config * test: updates tests for utils * ci: fix test command * test: add tests for format.ts * test: add snapshots * test: update snapshots * test: add tests for linkConverter * test: add tests for error.ts * test: update test coverage script name * test: update tests for prompt.ts * test: re-group utils, add tests * test: add tests for export.ts * test: add tests for sort.ts --- .gitignore | 5 + package.json | 14 +- .../pages/home/Messages/MessageMenubar.tsx | 3 +- src/renderer/src/services/MessagesService.ts | 3 +- .../__tests__/ModelMessageService.test.ts | 80 +-- .../__snapshots__/markdown.test.ts.snap | 124 ++++ src/renderer/src/utils/__tests__/api.test.ts | 70 +++ .../__tests__/blacklistMatchPattern.test.ts | 163 +++++ .../src/utils/__tests__/error.test.ts | 217 +++++++ .../src/utils/__tests__/export.test.ts | 135 ++++ .../src/utils/__tests__/extract.test.ts | 148 +++++ src/renderer/src/utils/__tests__/file.test.ts | 119 ++++ .../src/utils/__tests__/formats.test.ts | 469 ++++++++++++++ .../src/utils/__tests__/image.test.ts | 128 ++++ .../src/utils/__tests__/index.test.ts | 43 ++ src/renderer/src/utils/__tests__/json.test.ts | 44 ++ .../src/utils/__tests__/linkConverter.test.ts | 226 +++++++ .../src/utils/__tests__/markdown.test.ts | 161 +++++ .../src/utils/__tests__/naming.test.ts | 194 ++++++ .../src/utils/__tests__/prompt.test.ts | 71 +++ src/renderer/src/utils/__tests__/sort.test.ts | 110 ++++ .../src/utils/__tests__/style.test.ts | 78 +++ .../src/utils/blacklistMatchPattern.test.ts | 165 ----- src/renderer/src/utils/export.ts | 33 +- src/renderer/src/utils/file.ts | 57 ++ src/renderer/src/utils/formats.ts | 32 +- src/renderer/src/utils/image.ts | 170 +++++ src/renderer/src/utils/index.ts | 399 ++---------- src/renderer/src/utils/json.ts | 28 + src/renderer/src/utils/linkConverter.ts | 4 +- src/renderer/src/utils/markdown.ts | 66 +- src/renderer/src/utils/naming.ts | 151 +++++ src/renderer/src/utils/sort.ts | 34 + vitest.config.ts | 54 ++ yarn.lock | 590 +++++++++++++++++- 35 files changed, 3771 insertions(+), 617 deletions(-) create mode 100644 src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap create mode 100644 src/renderer/src/utils/__tests__/api.test.ts create mode 100644 src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts create mode 100644 src/renderer/src/utils/__tests__/error.test.ts create mode 100644 src/renderer/src/utils/__tests__/export.test.ts create mode 100644 src/renderer/src/utils/__tests__/extract.test.ts create mode 100644 src/renderer/src/utils/__tests__/file.test.ts create mode 100644 src/renderer/src/utils/__tests__/formats.test.ts create mode 100644 src/renderer/src/utils/__tests__/image.test.ts create mode 100644 src/renderer/src/utils/__tests__/index.test.ts create mode 100644 src/renderer/src/utils/__tests__/json.test.ts create mode 100644 src/renderer/src/utils/__tests__/linkConverter.test.ts create mode 100644 src/renderer/src/utils/__tests__/markdown.test.ts create mode 100644 src/renderer/src/utils/__tests__/naming.test.ts create mode 100644 src/renderer/src/utils/__tests__/prompt.test.ts create mode 100644 src/renderer/src/utils/__tests__/sort.test.ts create mode 100644 src/renderer/src/utils/__tests__/style.test.ts delete mode 100644 src/renderer/src/utils/blacklistMatchPattern.test.ts create mode 100644 src/renderer/src/utils/file.ts create mode 100644 src/renderer/src/utils/image.ts create mode 100644 src/renderer/src/utils/json.ts create mode 100644 src/renderer/src/utils/naming.ts create mode 100644 src/renderer/src/utils/sort.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index de77cf06..459dc620 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,8 @@ local .aider* .cursorrules .cursor/rules + +# test +coverage +.vitest-cache +vitest.config.*.timestamp-* diff --git a/package.json b/package.json index e06dc217..5e56f6ba 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,12 @@ "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "check:i18n": "node scripts/check-i18n.js", - "test": "npx -y tsx --test src/**/*.test.ts", + "test": "yarn test:renderer", + "test:coverage": "yarn test:renderer:coverage", + "test:node": "npx -y tsx --test src/**/*.test.ts", + "test:renderer": "vitest run", + "test:renderer:ui": "vitest --ui", + "test:renderer:coverage": "vitest run --coverage", "format": "prettier --write .", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "postinstall": "electron-builder install-app-deps", @@ -133,7 +138,9 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@types/tinycolor2": "^1", "@types/ws": "^8", - "@vitejs/plugin-react": "4.3.4", + "@vitejs/plugin-react": "^4.4.1", + "@vitest/coverage-v8": "^3.1.1", + "@vitest/ui": "^3.1.1", "analytics": "^0.8.16", "antd": "^5.22.5", "applescript": "^1.0.0", @@ -197,7 +204,8 @@ "tokenx": "^0.4.1", "typescript": "^5.6.2", "uuid": "^10.0.0", - "vite": "6.2.6" + "vite": "6.2.6", + "vitest": "^3.1.1" }, "resolutions": { "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index b936dd0c..1ad808f5 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -11,7 +11,7 @@ import { translateText } from '@renderer/services/TranslateService' import { RootState } from '@renderer/store' import type { Message, Model } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types' -import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils' +import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils' import { exportMarkdownToJoplin, exportMarkdownToNotion, @@ -21,6 +21,7 @@ import { messageToMarkdown } from '@renderer/utils/export' import { withMessageThought } from '@renderer/utils/formats' +import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown' import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' import dayjs from 'dayjs' import { clone } from 'lodash' diff --git a/src/renderer/src/services/MessagesService.ts b/src/renderer/src/services/MessagesService.ts index 14c024d9..f0846986 100644 --- a/src/renderer/src/services/MessagesService.ts +++ b/src/renderer/src/services/MessagesService.ts @@ -5,7 +5,8 @@ import i18n from '@renderer/i18n' import { fetchMessagesSummary } from '@renderer/services/ApiService' import store from '@renderer/store' import { Assistant, Message, Model, Topic } from '@renderer/types' -import { getTitleFromString, uuid } from '@renderer/utils' +import { uuid } from '@renderer/utils' +import { getTitleFromString } from '@renderer/utils/export' import dayjs from 'dayjs' import { t } from 'i18next' import { isEmpty, remove, takeRight } from 'lodash' diff --git a/src/renderer/src/services/__tests__/ModelMessageService.test.ts b/src/renderer/src/services/__tests__/ModelMessageService.test.ts index 621e49d6..9a2a0b38 100644 --- a/src/renderer/src/services/__tests__/ModelMessageService.test.ts +++ b/src/renderer/src/services/__tests__/ModelMessageService.test.ts @@ -1,11 +1,10 @@ -import assert from 'node:assert' -import { test } from 'node:test' - +import { Model } from '@renderer/types' import { ChatCompletionMessageParam } from 'openai/resources' +import { describe, expect, it } from 'vitest' -const { processReqMessages } = require('../ModelMessageService') +import { processReqMessages } from '../ModelMessageService' -test('ModelMessageService', async (t) => { +describe('ModelMessageService', () => { const mockMessages: ChatCompletionMessageParam[] = [ { role: 'user', content: 'First question' }, { role: 'user', content: 'Additional context' }, @@ -15,56 +14,63 @@ test('ModelMessageService', async (t) => { { role: 'assistant', content: 'Second answer' } ] - await t.test('should merge successive messages with same role for deepseek-reasoner model', () => { - const model = { id: 'deepseek-reasoner' } + const createModel = (id: string): Model => ({ + id, + provider: 'test-provider', + name: id, + group: 'test-group' + }) + + it('should merge successive messages with same role for deepseek-reasoner model', () => { + const model = createModel('deepseek-reasoner') const result = processReqMessages(model, mockMessages) - assert.strictEqual(result.length, 4) - assert.deepStrictEqual(result[0], { + expect(result.length).toBe(4) + expect(result[0]).toEqual({ role: 'user', content: 'First question\nAdditional context' }) - assert.deepStrictEqual(result[1], { + expect(result[1]).toEqual({ role: 'assistant', content: 'First answer\nAdditional information' }) - assert.deepStrictEqual(result[2], { + expect(result[2]).toEqual({ role: 'user', content: 'Second question' }) - assert.deepStrictEqual(result[3], { + expect(result[3]).toEqual({ role: 'assistant', content: 'Second answer' }) }) - await t.test('should not merge messages for other models', () => { - const model = { id: 'gpt-4' } + it('should not merge messages for other models', () => { + const model = createModel('gpt-4') const result = processReqMessages(model, mockMessages) - assert.strictEqual(result.length, mockMessages.length) - assert.deepStrictEqual(result, mockMessages) + expect(result.length).toBe(mockMessages.length) + expect(result).toEqual(mockMessages) }) - await t.test('should handle empty messages array', () => { - const model = { id: 'deepseek-reasoner' } + it('should handle empty messages array', () => { + const model = createModel('deepseek-reasoner') const result = processReqMessages(model, []) - assert.strictEqual(result.length, 0) - assert.deepStrictEqual(result, []) + expect(result.length).toBe(0) + expect(result).toEqual([]) }) - await t.test('should handle single message', () => { - const model = { id: 'deepseek-reasoner' } + it('should handle single message', () => { + const model = createModel('deepseek-reasoner') const singleMessage = [{ role: 'user', content: 'Single message' }] - const result = processReqMessages(model, singleMessage) + const result = processReqMessages(model, singleMessage as ChatCompletionMessageParam[]) - assert.strictEqual(result.length, 1) - assert.deepStrictEqual(result, singleMessage) + expect(result.length).toBe(1) + expect(result).toEqual(singleMessage) }) - await t.test('should preserve other message properties when merging', () => { - const model = { id: 'deepseek-reasoner' } + it('should preserve other message properties when merging', () => { + const model = createModel('deepseek-reasoner') const messagesWithProps = [ { role: 'user', @@ -81,8 +87,8 @@ test('ModelMessageService', async (t) => { const result = processReqMessages(model, messagesWithProps) - assert.strictEqual(result.length, 1) - assert.deepStrictEqual(result[0], { + expect(result.length).toBe(1) + expect(result[0]).toEqual({ role: 'user', content: 'First message\nSecond message', name: 'user1', @@ -90,8 +96,8 @@ test('ModelMessageService', async (t) => { }) }) - await t.test('should handle alternating roles correctly', () => { - const model = { id: 'deepseek-reasoner' } + it('should handle alternating roles correctly', () => { + const model = createModel('deepseek-reasoner') const alternatingMessages = [ { role: 'user', content: 'Q1' }, { role: 'assistant', content: 'A1' }, @@ -101,12 +107,12 @@ test('ModelMessageService', async (t) => { const result = processReqMessages(model, alternatingMessages) - assert.strictEqual(result.length, 4) - assert.deepStrictEqual(result, alternatingMessages) + expect(result.length).toBe(4) + expect(result).toEqual(alternatingMessages) }) - await t.test('should handle messages with empty content', () => { - const model = { id: 'deepseek-reasoner' } + it('should handle messages with empty content', () => { + const model = createModel('deepseek-reasoner') const messagesWithEmpty = [ { role: 'user', content: 'Q1' }, { role: 'user', content: '' }, @@ -115,8 +121,8 @@ test('ModelMessageService', async (t) => { const result = processReqMessages(model, messagesWithEmpty) - assert.strictEqual(result.length, 1) - assert.deepStrictEqual(result[0], { + expect(result.length).toBe(1) + expect(result[0]).toEqual({ role: 'user', content: 'Q1\n\nQ2' }) diff --git a/src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap b/src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap new file mode 100644 index 00000000..3765a190 --- /dev/null +++ b/src/renderer/src/utils/__tests__/__snapshots__/markdown.test.ts.snap @@ -0,0 +1,124 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`markdown > markdown configuration constants > sanitizeSchema matches snapshot 1`] = ` +{ + "attributes": { + "*": [ + "className", + "style", + "id", + "title", + ], + "a": [ + "href", + "target", + "rel", + ], + "circle": [ + "cx", + "cy", + "r", + "fill", + "stroke", + ], + "g": [ + "transform", + "fill", + "stroke", + ], + "line": [ + "x1", + "y1", + "x2", + "y2", + "stroke", + ], + "path": [ + "d", + "fill", + "stroke", + "strokeWidth", + "strokeLinecap", + "strokeLinejoin", + ], + "polygon": [ + "points", + "fill", + "stroke", + ], + "polyline": [ + "points", + "fill", + "stroke", + ], + "rect": [ + "x", + "y", + "width", + "height", + "fill", + "stroke", + ], + "svg": [ + "viewBox", + "width", + "height", + "xmlns", + "fill", + "stroke", + ], + "text": [ + "x", + "y", + "fill", + "textAnchor", + "dominantBaseline", + ], + }, + "tagNames": [ + "style", + "p", + "div", + "span", + "b", + "i", + "strong", + "em", + "ul", + "ol", + "li", + "table", + "tr", + "td", + "th", + "thead", + "tbody", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "blockquote", + "pre", + "code", + "br", + "hr", + "svg", + "path", + "circle", + "rect", + "line", + "polyline", + "polygon", + "text", + "g", + "defs", + "title", + "desc", + "tspan", + "sub", + "sup", + ], +} +`; diff --git a/src/renderer/src/utils/__tests__/api.test.ts b/src/renderer/src/utils/__tests__/api.test.ts new file mode 100644 index 00000000..3b613da1 --- /dev/null +++ b/src/renderer/src/utils/__tests__/api.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' + +import { formatApiHost, maskApiKey } from '../api' + +describe('api', () => { + describe('formatApiHost', () => { + it('should return original host when it ends with a slash', () => { + expect(formatApiHost('https://api.example.com/')).toBe('https://api.example.com/') + expect(formatApiHost('http://localhost:5173/')).toBe('http://localhost:5173/') + }) + + it('should return original host when it ends with volces.com/api/v3', () => { + expect(formatApiHost('https://api.volces.com/api/v3')).toBe('https://api.volces.com/api/v3') + expect(formatApiHost('http://volces.com/api/v3')).toBe('http://volces.com/api/v3') + }) + + it('should append /v1/ to hosts that do not match special conditions', () => { + expect(formatApiHost('https://api.example.com')).toBe('https://api.example.com/v1/') + expect(formatApiHost('http://localhost:5173')).toBe('http://localhost:5173/v1/') + expect(formatApiHost('https://api.openai.com')).toBe('https://api.openai.com/v1/') + }) + + it('should not modify hosts that already have a path but do not end with a slash', () => { + expect(formatApiHost('https://api.example.com/custom')).toBe('https://api.example.com/custom/v1/') + }) + + it('should handle empty string gracefully', () => { + expect(formatApiHost('')).toBe('/v1/') + }) + }) + + describe('maskApiKey', () => { + it('should return empty string when key is empty', () => { + expect(maskApiKey('')).toBe('') + expect(maskApiKey(null as unknown as string)).toBe('') + expect(maskApiKey(undefined as unknown as string)).toBe('') + }) + + it('should mask keys longer than 24 characters', () => { + const key = '1234567890abcdefghijklmnopqrstuvwxyz' + expect(maskApiKey(key)).toBe('12345678****stuvwxyz') + }) + + it('should mask keys longer than 16 characters but not longer than 24', () => { + const key = '1234567890abcdefgh' + expect(maskApiKey(key)).toBe('1234****efgh') + }) + + it('should mask keys longer than 8 characters but not longer than 16', () => { + const key = '1234567890' + expect(maskApiKey(key)).toBe('12****90') + }) + + it('should not mask keys that are 8 characters or shorter', () => { + expect(maskApiKey('12345678')).toBe('12345678') + expect(maskApiKey('123')).toBe('123') + }) + + it('should handle keys at exactly the boundary conditions', () => { + // 24 characters + expect(maskApiKey('123456789012345678901234')).toBe('1234****1234') + + // 16 characters + expect(maskApiKey('1234567890123456')).toBe('12****56') + + // 8 characters + expect(maskApiKey('12345678')).toBe('12345678') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts new file mode 100644 index 00000000..4656e305 --- /dev/null +++ b/src/renderer/src/utils/__tests__/blacklistMatchPattern.test.ts @@ -0,0 +1,163 @@ +/* + * MIT License + * + * Copyright (c) 2018 iorate + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * https://github.com/iorate/ublacklist + */ + +import { describe, expect, it } from 'vitest' + +import { MatchPatternMap } from '../blacklistMatchPattern' + +function get(map: MatchPatternMap, url: string) { + return map.get(url).sort() +} + +describe('blacklistMatchPattern', () => { + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns + it('MDN Examples', () => { + const map = new MatchPatternMap() + map.set('', 0) + map.set('*://*/*', 1) + map.set('*://*.mozilla.org/*', 2) + map.set('*://mozilla.org/', 3) + expect(() => map.set('ftp://mozilla.org/', 4)).toThrow() + map.set('https://*/path', 5) + map.set('https://*/path/', 6) + map.set('https://mozilla.org/*', 7) + map.set('https://mozilla.org/a/b/c/', 8) + map.set('https://mozilla.org/*/b/*/', 9) + expect(() => map.set('file:///blah/*', 10)).toThrow() + // + expect(get(map, 'http://example.org/')).toEqual([0, 1]) + expect(get(map, 'https://a.org/some/path/')).toEqual([0, 1]) + expect(get(map, 'ws://sockets.somewhere.org/')).toEqual([]) + expect(get(map, 'wss://ws.example.com/stuff/')).toEqual([]) + expect(get(map, 'ftp://files.somewhere.org/')).toEqual([]) + expect(get(map, 'resource://a/b/c/')).toEqual([]) + expect(get(map, 'ftps://files.somewhere.org/')).toEqual([]) + // *://*/* + expect(get(map, 'http://example.org/')).toEqual([0, 1]) + expect(get(map, 'https://a.org/some/path/')).toEqual([0, 1]) + expect(get(map, 'ws://sockets.somewhere.org/')).toEqual([]) + expect(get(map, 'wss://ws.example.com/stuff/')).toEqual([]) + expect(get(map, 'ftp://ftp.example.org/')).toEqual([]) + expect(get(map, 'file:///a/')).toEqual([]) + // *://*.mozilla.org/* + expect(get(map, 'http://mozilla.org/')).toEqual([0, 1, 2, 3]) + expect(get(map, 'https://mozilla.org/')).toEqual([0, 1, 2, 3, 7]) + expect(get(map, 'http://a.mozilla.org/')).toEqual([0, 1, 2]) + expect(get(map, 'http://a.b.mozilla.org/')).toEqual([0, 1, 2]) + expect(get(map, 'https://b.mozilla.org/path/')).toEqual([0, 1, 2, 6]) + expect(get(map, 'ws://ws.mozilla.org/')).toEqual([]) + expect(get(map, 'wss://secure.mozilla.org/something')).toEqual([]) + expect(get(map, 'ftp://mozilla.org/')).toEqual([]) + expect(get(map, 'http://mozilla.com/')).toEqual([0, 1]) + expect(get(map, 'http://firefox.org/')).toEqual([0, 1]) + // *://mozilla.org/ + expect(get(map, 'http://mozilla.org/')).toEqual([0, 1, 2, 3]) + expect(get(map, 'https://mozilla.org/')).toEqual([0, 1, 2, 3, 7]) + expect(get(map, 'ws://mozilla.org/')).toEqual([]) + expect(get(map, 'wss://mozilla.org/')).toEqual([]) + expect(get(map, 'ftp://mozilla.org/')).toEqual([]) + expect(get(map, 'http://a.mozilla.org/')).toEqual([0, 1, 2]) + expect(get(map, 'http://mozilla.org/a')).toEqual([0, 1, 2]) + // ftp://mozilla.org/ + expect(get(map, 'ftp://mozilla.org/')).toEqual([]) + expect(get(map, 'http://mozilla.org/')).toEqual([0, 1, 2, 3]) + expect(get(map, 'ftp://sub.mozilla.org/')).toEqual([]) + expect(get(map, 'ftp://mozilla.org/path')).toEqual([]) + // https://*/path + expect(get(map, 'https://mozilla.org/path')).toEqual([0, 1, 2, 5, 7]) + expect(get(map, 'https://a.mozilla.org/path')).toEqual([0, 1, 2, 5]) + expect(get(map, 'https://something.com/path')).toEqual([0, 1, 5]) + expect(get(map, 'http://mozilla.org/path')).toEqual([0, 1, 2]) + expect(get(map, 'https://mozilla.org/path/')).toEqual([0, 1, 2, 6, 7]) + expect(get(map, 'https://mozilla.org/a')).toEqual([0, 1, 2, 7]) + expect(get(map, 'https://mozilla.org/')).toEqual([0, 1, 2, 3, 7]) + expect(get(map, 'https://mozilla.org/path?foo=1')).toEqual([0, 1, 2, 7]) + // https://*/path/ + expect(get(map, 'http://mozilla.org/path/')).toEqual([0, 1, 2]) + expect(get(map, 'https://a.mozilla.org/path/')).toEqual([0, 1, 2, 6]) + expect(get(map, 'https://something.com/path/')).toEqual([0, 1, 6]) + expect(get(map, 'http://mozilla.org/path/')).toEqual([0, 1, 2]) + expect(get(map, 'https://mozilla.org/path')).toEqual([0, 1, 2, 5, 7]) + expect(get(map, 'https://mozilla.org/a')).toEqual([0, 1, 2, 7]) + expect(get(map, 'https://mozilla.org/')).toEqual([0, 1, 2, 3, 7]) + expect(get(map, 'https://mozilla.org/path?foo=1')).toEqual([0, 1, 2, 7]) + // https://mozilla.org/* + expect(get(map, 'https://mozilla.org/')).toEqual([0, 1, 2, 3, 7]) + expect(get(map, 'https://mozilla.org/path')).toEqual([0, 1, 2, 5, 7]) + expect(get(map, 'https://mozilla.org/another')).toEqual([0, 1, 2, 7]) + expect(get(map, 'https://mozilla.org/path/to/doc')).toEqual([0, 1, 2, 7]) + expect(get(map, 'https://mozilla.org/path/to/doc?foo=1')).toEqual([0, 1, 2, 7]) + // https://mozilla.org/a/b/c/ + expect(get(map, 'https://mozilla.org/a/b/c/')).toEqual([0, 1, 2, 7, 8, 9]) + expect(get(map, 'https://mozilla.org/a/b/c/#section1')).toEqual([0, 1, 2, 7, 8, 9]) + // https://mozilla.org/*/b/*/ + expect(get(map, 'https://mozilla.org/a/b/c/')).toEqual([0, 1, 2, 7, 8, 9]) + expect(get(map, 'https://mozilla.org/d/b/f/')).toEqual([0, 1, 2, 7, 9]) + expect(get(map, 'https://mozilla.org/a/b/c/d/')).toEqual([0, 1, 2, 7, 9]) + expect(get(map, 'https://mozilla.org/a/b/c/d/#section1')).toEqual([0, 1, 2, 7, 9]) + expect(get(map, 'https://mozilla.org/a/b/c/d/?foo=/')).toEqual([0, 1, 2, 7, 9]) + expect(get(map, 'https://mozilla.org/a?foo=21314&bar=/b/&extra=c/')).toEqual([0, 1, 2, 7, 9]) + expect(get(map, 'https://mozilla.org/b/*/')).toEqual([0, 1, 2, 7]) + expect(get(map, 'https://mozilla.org/a/b/')).toEqual([0, 1, 2, 7]) + expect(get(map, 'https://mozilla.org/a/b/c/d/?foo=bar')).toEqual([0, 1, 2, 7]) + // file:///blah/* + expect(get(map, 'file:///blah/')).toEqual([]) + expect(get(map, 'file:///blah/bleh')).toEqual([]) + expect(get(map, 'file:///bleh/')).toEqual([]) + // Invalid match patterns + expect(() => map.set('resource://path/', 11)).toThrow() + expect(() => map.set('https://mozilla.org', 12)).toThrow() + expect(() => map.set('https://mozilla.*.org/', 13)).toThrow() + expect(() => map.set('https://*zilla.org', 14)).toThrow() + expect(() => map.set('http*://mozilla.org/', 15)).toThrow() + expect(() => map.set('https://mozilla.org:80/', 16)).toThrow() + expect(() => map.set('*//*', 17)).toThrow() + expect(() => map.set('file://*', 18)).toThrow() + }) + + it('Serialization', () => { + let map = new MatchPatternMap() + map.set('', 0) + map.set('*://*/*', 1) + map.set('*://*.mozilla.org/*', 2) + map.set('*://mozilla.org/', 3) + map.set('https://*/path', 5) + map.set('https://*/path/', 6) + map.set('https://mozilla.org/*', 7) + map.set('https://mozilla.org/a/b/c/', 8) + map.set('https://mozilla.org/*/b/*/', 9) + const json = map.toJSON() + expect(JSON.stringify(json)).toBe( + '[[0],[[],[[1],[5,"https","/path"],[6,"https","/path/"]],{"org":[[],[],{"mozilla":[[[3,"*","/"],[7,"https"],[8,"https","/a/b/c/"],[9,"https","/*/b/*/"]],[[2]]]}]}]]' + ) + map = new MatchPatternMap(json) + expect(get(map, 'http://mozilla.org/')).toEqual([0, 1, 2, 3]) + expect(get(map, 'https://mozilla.org/')).toEqual([0, 1, 2, 3, 7]) + expect(get(map, 'http://a.mozilla.org/')).toEqual([0, 1, 2]) + expect(get(map, 'http://a.b.mozilla.org/')).toEqual([0, 1, 2]) + expect(get(map, 'https://b.mozilla.org/path/')).toEqual([0, 1, 2, 6]) + }) +}) diff --git a/src/renderer/src/utils/__tests__/error.test.ts b/src/renderer/src/utils/__tests__/error.test.ts new file mode 100644 index 00000000..078b6724 --- /dev/null +++ b/src/renderer/src/utils/__tests__/error.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest' + +import { formatErrorMessage, formatMessageError, getErrorDetails, getErrorMessage, isAbortError } from '../error' + +describe('error', () => { + describe('getErrorDetails', () => { + it('should handle null or non-object values', () => { + expect(getErrorDetails(null)).toBeNull() + expect(getErrorDetails('string error')).toBe('string error') + expect(getErrorDetails(123)).toBe(123) + }) + + it('should handle circular references', () => { + const circularObj: any = {} + circularObj.self = circularObj + + const result = getErrorDetails(circularObj) + expect(result).toEqual({ self: circularObj }) + }) + + it('should extract properties from Error objects', () => { + const error = new Error('Test error') + const result = getErrorDetails(error) + + expect(result.message).toBe('Test error') + expect(result.stack).toBeDefined() + }) + + it('should skip function properties', () => { + const objWithFunction = { + prop: 'value', + func: () => 'function' + } + + const result = getErrorDetails(objWithFunction) + expect(result.prop).toBe('value') + expect(result.func).toBeUndefined() + }) + + it('should handle nested objects', () => { + const nestedError = { + message: 'Outer error', + cause: new Error('Inner error') + } + + const result = getErrorDetails(nestedError) + expect(result.message).toBe('Outer error') + expect(result.cause.message).toBe('Inner error') + }) + }) + + describe('formatErrorMessage', () => { + it('should format error as JSON string', () => { + console.error = vi.fn() // Mock console.error + + const error = new Error('Test error') + const result = formatErrorMessage(error) + + expect(console.error).toHaveBeenCalled() + expect(result).toContain('```json') + expect(result).toContain('"message": "Test error"') + expect(result).not.toContain('"stack":') + }) + + it('should remove sensitive information', () => { + console.error = vi.fn() + + const error = { + message: 'API error', + headers: { Authorization: 'Bearer token' }, + stack: 'Error stack trace', + request_id: '12345' + } + + const result = formatErrorMessage(error) + + expect(result).toContain('"message": "API error"') + expect(result).not.toContain('Authorization') + expect(result).not.toContain('stack') + expect(result).not.toContain('request_id') + }) + + it('should handle errors during formatting', () => { + console.error = vi.fn() + + const problematicError = { + get message() { + throw new Error('Cannot access message') + } + } + + const result = formatErrorMessage(problematicError) + expect(result).toContain('```') + expect(result).toContain('Unable') + }) + + it('should handle non-serializable errors', () => { + console.error = vi.fn() + + const nonSerializableError = { + toString() { + throw new Error('Cannot convert to string') + } + } + + try { + Object.defineProperty(nonSerializableError, 'toString', { + get() { + throw new Error('Cannot access toString') + } + }) + } catch (e) { + // Ignore + } + + const result = formatErrorMessage(nonSerializableError) + expect(result).toBeTruthy() + }) + }) + + describe('formatMessageError', () => { + it('should return error details as an object', () => { + const error = new Error('Test error') + const result = formatMessageError(error) + + expect(result.message).toBe('Test error') + expect(result.stack).toBeUndefined() + expect(result.headers).toBeUndefined() + expect(result.request_id).toBeUndefined() + }) + + it('should handle string errors', () => { + const result = formatMessageError('String error') + expect(typeof result).toBe('string') + expect(result).toBe('String error') + }) + + it('should handle formatting errors', () => { + const problematicError = { + get message() { + throw new Error('Cannot access') + }, + toString: () => 'Error object' + } + + const result = formatMessageError(problematicError) + expect(result).toBeTruthy() + }) + + it('should handle completely invalid errors', () => { + let invalidError: any + try { + invalidError = Object.create(null) + Object.defineProperty(invalidError, 'toString', { + get: () => { + throw new Error() + } + }) + } catch (e) { + invalidError = { + toString() { + throw new Error() + } + } + } + + const result = formatMessageError(invalidError) + expect(result).toBeTruthy() + }) + }) + + describe('getErrorMessage', () => { + it('should extract message from Error objects', () => { + const error = new Error('Test message') + expect(getErrorMessage(error)).toBe('Test message') + }) + + it('should handle objects with message property', () => { + const errorObj = { message: 'Object message' } + expect(getErrorMessage(errorObj)).toBe('Object message') + }) + + it('should convert non-Error objects to string', () => { + const obj = { toString: () => 'Custom toString' } + expect(getErrorMessage(obj)).toBe('Custom toString') + }) + + it('should return empty string for undefined or null', () => { + expect(getErrorMessage(undefined)).toBe('') + expect(getErrorMessage(null)).toBe('') + }) + }) + + describe('isAbortError', () => { + it('should identify OpenAI abort errors by message', () => { + const openaiError = { message: 'Request was aborted.' } + expect(isAbortError(openaiError)).toBe(true) + }) + + it('should identify DOM AbortError', () => { + const domError = new DOMException('The operation was aborted', 'AbortError') + expect(isAbortError(domError)).toBe(true) + }) + + it('should identify aborted signal errors', () => { + const signalError = { message: 'The operation was aborted because signal is aborted without reason' } + expect(isAbortError(signalError)).toBe(true) + }) + + it('should return false for other errors', () => { + expect(isAbortError(new Error('Generic error'))).toBe(false) + expect(isAbortError({ message: 'Not an abort error' })).toBe(false) + expect(isAbortError('String error')).toBe(false) + expect(isAbortError(null)).toBe(false) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/export.test.ts b/src/renderer/src/utils/__tests__/export.test.ts new file mode 100644 index 00000000..e1de886c --- /dev/null +++ b/src/renderer/src/utils/__tests__/export.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { getTitleFromString, messagesToMarkdown, messageToMarkdown, messageToMarkdownWithReasoning } from '../export' + +// 辅助函数:生成完整 Message 对象 +function createMessage(partial) { + return { + id: partial.id || 'id', + assistantId: partial.assistantId || 'a', + role: partial.role, + content: partial.content, + topicId: partial.topicId || 't', + createdAt: partial.createdAt || '2024-01-01', + updatedAt: partial.updatedAt || 0, + status: partial.status || 'success', + type: partial.type || 'text', + ...partial + } +} + +describe('export', () => { + describe('getTitleFromString', () => { + it('should extract first line before punctuation', () => { + expect(getTitleFromString('标题。其余内容')).toBe('标题') + expect(getTitleFromString('标题,其余内容')).toBe('标题') + expect(getTitleFromString('标题.其余内容')).toBe('标题') + expect(getTitleFromString('标题,其余内容')).toBe('标题') + }) + + it('should extract first line if no punctuation', () => { + expect(getTitleFromString('第一行\n第二行')).toBe('第一行') + }) + + it('should truncate if too long', () => { + expect(getTitleFromString('a'.repeat(100), 10)).toBe('a'.repeat(10)) + }) + + it('should return slice if first line empty', () => { + expect(getTitleFromString('\nabc', 2)).toBe('ab') + }) + + it('should handle empty string', () => { + expect(getTitleFromString('', 5)).toBe('') + }) + + it('should handle only punctuation', () => { + expect(getTitleFromString('。', 5)).toBe('。') + }) + + it('should handle only whitespace', () => { + expect(getTitleFromString(' ', 2)).toBe(' ') + }) + + it('should handle non-ascii', () => { + expect(getTitleFromString('你好,世界')).toBe('你好') + }) + }) + + describe('messageToMarkdown', () => { + beforeEach(() => { + vi.resetModules() + vi.doMock('@renderer/store', () => ({ + default: { getState: () => ({ settings: { forceDollarMathInMarkdown: false } }) } + })) + }) + + it('should format user message', () => { + const msg = createMessage({ role: 'user', content: 'hello', id: '1' }) + expect(messageToMarkdown(msg)).toContain('### 🧑‍💻 User') + expect(messageToMarkdown(msg)).toContain('hello') + }) + + it('should format assistant message', () => { + const msg = createMessage({ role: 'assistant', content: 'hi', id: '2' }) + expect(messageToMarkdown(msg)).toContain('### 🤖 Assistant') + expect(messageToMarkdown(msg)).toContain('hi') + }) + }) + + describe('messageToMarkdownWithReasoning', () => { + beforeEach(() => { + vi.resetModules() + vi.doMock('@renderer/store', () => ({ + default: { getState: () => ({ settings: { forceDollarMathInMarkdown: false } }) } + })) + vi.doMock('@renderer/i18n', () => ({ + default: { t: (k: string) => k } + })) + }) + + it('should include reasoning content in details', () => { + const msg = createMessage({ role: 'assistant', content: 'hi', reasoning_content: '思考内容', id: '5' }) + expect(messageToMarkdownWithReasoning(msg)).toContain(' tag and newlines', () => { + const msg = createMessage({ role: 'assistant', content: 'hi', reasoning_content: '\nA\nB', id: '6' }) + expect(messageToMarkdownWithReasoning(msg)).toContain('A
B') + }) + + it('should fallback if no reasoning_content', () => { + const msg = createMessage({ role: 'assistant', content: 'hi', id: '7' }) + expect(messageToMarkdownWithReasoning(msg)).toContain('hi') + }) + }) + + describe('messagesToMarkdown', () => { + beforeEach(() => { + vi.resetModules() + vi.doMock('@renderer/store', () => ({ + default: { getState: () => ({ settings: { forceDollarMathInMarkdown: false } }) } + })) + }) + + it('should join multiple messages', () => { + const msgs = [ + createMessage({ role: 'user', content: 'a', id: '9' }), + createMessage({ role: 'assistant', content: 'b', id: '10' }) + ] + expect(messagesToMarkdown(msgs)).toContain('a') + expect(messagesToMarkdown(msgs)).toContain('b') + expect(messagesToMarkdown(msgs).split('---').length).toBe(2) + }) + + it('should handle empty array', () => { + expect(messagesToMarkdown([])).toBe('') + }) + + it('should handle single message', () => { + const msgs = [createMessage({ role: 'user', content: 'a', id: '13' })] + expect(messagesToMarkdown(msgs)).toContain('a') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/extract.test.ts b/src/renderer/src/utils/__tests__/extract.test.ts new file mode 100644 index 00000000..c4baf8cc --- /dev/null +++ b/src/renderer/src/utils/__tests__/extract.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest' + +import { extractInfoFromXML } from '../extract' + +describe('extract', () => { + describe('extractInfoFromXML', () => { + it('should parse websearch XML with questions and links', () => { + const xml = ` + + What is the capital of France? + How many people live in Paris? + https://en.wikipedia.org/wiki/Paris + https://www.paris.fr/ + + ` + + const result = extractInfoFromXML(xml) + + expect(result).toEqual({ + websearch: { + question: ['What is the capital of France?', 'How many people live in Paris?'], + links: ['https://en.wikipedia.org/wiki/Paris', 'https://www.paris.fr/'] + } + }) + }) + + it('should parse knowledge XML with rewrite and questions', () => { + const xml = ` + + This is a rewritten query + What is artificial intelligence? + Who invented machine learning? + + ` + + const result = extractInfoFromXML(xml) + + expect(result).toEqual({ + knowledge: { + rewrite: 'This is a rewritten query', + question: ['What is artificial intelligence?', 'Who invented machine learning?'] + } + }) + }) + + it('should parse XML with both websearch and knowledge wrapped in root tag', () => { + const xml = ` + + + What is climate change? + https://en.wikipedia.org/wiki/Climate_change + + + climate change effects + What are the effects of climate change? + + + ` + + const result = extractInfoFromXML(xml) + + // 注意:当使用 root 标签包裹时,结果包含 root 属性 + expect(result).toEqual({ + root: { + websearch: { + question: ['What is climate change?'], + links: ['https://en.wikipedia.org/wiki/Climate_change'] + }, + knowledge: { + rewrite: 'climate change effects', + question: ['What are the effects of climate change?'] + } + } + }) + }) + + it('should handle XML with single question and no links', () => { + const xml = ` + + Single question? + + ` + + const result = extractInfoFromXML(xml) + + expect(result).toEqual({ + websearch: { + question: ['Single question?'] + } + }) + }) + + it('should handle XML with special characters', () => { + const xml = ` + + What is the meaning of <div> in HTML? + https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div + + ` + + const result = extractInfoFromXML(xml) + + expect(result).toEqual({ + websearch: { + question: ['What is the meaning of
in HTML?'], + links: ['https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div'] + } + }) + }) + + it('should handle invalid XML gracefully', () => { + const invalidXml = ` + + Incomplete tag + https://example.com + + ` + + // 注意:XMLParser 能够处理一些无效的 XML + const result = extractInfoFromXML(invalidXml) + expect(result).toBeDefined() + }) + + it('should handle empty XML input', () => { + // 注意:XMLParser 会尝试解析空字符串 + const result = extractInfoFromXML('') + expect(result).toEqual({}) + }) + + it('should handle XML with empty tags', () => { + const xml = ` + + + + + ` + + const result = extractInfoFromXML(xml) + + expect(result).toEqual({ + websearch: { + question: [''], + links: [''] + } + }) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/file.test.ts b/src/renderer/src/utils/__tests__/file.test.ts new file mode 100644 index 00000000..fc91b9d9 --- /dev/null +++ b/src/renderer/src/utils/__tests__/file.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { formatFileSize, getFileDirectory, getFileExtension, removeSpecialCharactersForFileName } from '../file' + +describe('file', () => { + describe('getFileDirectory', () => { + it('should return directory path for normal file path', () => { + // 验证普通文件路径的目录提取 + const filePath = 'path/to/file.txt' + const result = getFileDirectory(filePath) + expect(result).toBe('path/to') + }) + + it('should return empty string for file without directory', () => { + // 验证没有目录的文件路径 + const filePath = 'file.txt' + const result = getFileDirectory(filePath) + expect(result).toBe('') + }) + + it('should handle absolute path correctly', () => { + // 验证绝对路径的目录提取 + const filePath = '/root/path/to/file.txt' + const result = getFileDirectory(filePath) + expect(result).toBe('/root/path/to') + }) + + it('should handle empty string input', () => { + // 验证空字符串输入的边界情况 + const filePath = '' + const result = getFileDirectory(filePath) + expect(result).toBe('') + }) + }) + + describe('getFileExtension', () => { + it('should return lowercase extension for normal file', () => { + // 验证普通文件的扩展名提取 + const filePath = 'document.pdf' + const result = getFileExtension(filePath) + expect(result).toBe('.pdf') + }) + + it('should convert uppercase extension to lowercase', () => { + // 验证大写扩展名转换为小写 + const filePath = 'image.PNG' + const result = getFileExtension(filePath) + expect(result).toBe('.png') + }) + + it('should return dot only for file without extension', () => { + // 验证没有扩展名的文件 + const filePath = 'noextension' + const result = getFileExtension(filePath) + expect(result).toBe('.') + }) + + it('should handle hidden files with extension', () => { + // 验证带有扩展名的隐藏文件 + const filePath = '.config.json' + const result = getFileExtension(filePath) + expect(result).toBe('.json') + }) + + it('should handle empty string input', () => { + // 验证空字符串输入的边界情况 + const filePath = '' + const result = getFileExtension(filePath) + expect(result).toBe('.') + }) + }) + + describe('formatFileSize', () => { + it('should format size in MB for large files', () => { + // 验证大文件以 MB 为单位格式化 + const size = 1048576 // 1MB + const result = formatFileSize(size) + expect(result).toBe('1.0 MB') + }) + + it('should format size in KB for medium files', () => { + // 验证中等大小文件以 KB 为单位格式化 + const size = 1024 // 1KB + const result = formatFileSize(size) + expect(result).toBe('1 KB') + }) + + it('should format small size in KB with decimals', () => { + // 验证小文件以 KB 为单位并带小数 + const size = 500 + const result = formatFileSize(size) + expect(result).toBe('0.49 KB') + }) + + it('should handle zero size', () => { + // 验证零大小的边界情况 + const size = 0 + const result = formatFileSize(size) + expect(result).toBe('0.00 KB') + }) + }) + + describe('removeSpecialCharactersForFileName', () => { + it('should remove invalid characters for filename', () => { + // 验证移除文件名中的非法字符 + expect(removeSpecialCharactersForFileName('Hello:<>World\nTest')).toBe('Hello___World Test') + }) + + it('should return original string if no invalid characters', () => { + // 验证没有非法字符的字符串 + expect(removeSpecialCharactersForFileName('HelloWorld')).toBe('HelloWorld') + }) + + it('should return empty string for empty input', () => { + // 验证空字符串 + expect(removeSpecialCharactersForFileName('')).toBe('') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/formats.test.ts b/src/renderer/src/utils/__tests__/formats.test.ts new file mode 100644 index 00000000..39cf9d5e --- /dev/null +++ b/src/renderer/src/utils/__tests__/formats.test.ts @@ -0,0 +1,469 @@ +import { isReasoningModel } from '@renderer/config/models' +import { getAssistantById } from '@renderer/services/AssistantService' +import { Message } from '@renderer/types' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + addImageFileToContents, + escapeBrackets, + escapeDollarNumber, + extractTitle, + removeSvgEmptyLines, + withGeminiGrounding, + withGenerateImage, + withMessageThought +} from '../formats' + +// 模拟依赖 +vi.mock('@renderer/config/models', () => ({ + isReasoningModel: vi.fn() +})) + +vi.mock('@renderer/services/AssistantService', () => ({ + getAssistantById: vi.fn() +})) + +describe('formats', () => { + describe('escapeDollarNumber', () => { + it('should escape dollar signs followed by numbers', () => { + expect(escapeDollarNumber('The cost is $5')).toBe('The cost is \\$5') + expect(escapeDollarNumber('$1, $2, and $3')).toBe('\\$1, \\$2, and \\$3') + }) + + it('should not escape dollar signs not followed by numbers', () => { + expect(escapeDollarNumber('The $ symbol')).toBe('The $ symbol') + expect(escapeDollarNumber('$symbol')).toBe('$symbol') + }) + + it('should handle empty string', () => { + expect(escapeDollarNumber('')).toBe('') + }) + + it('should handle string with only dollar signs', () => { + expect(escapeDollarNumber('$$$')).toBe('$$$') + }) + + it('should handle dollar sign at the end of string', () => { + expect(escapeDollarNumber('The cost is $')).toBe('The cost is $') + }) + }) + + describe('escapeBrackets', () => { + it('should convert \\[...\\] to display math format', () => { + expect(escapeBrackets('The formula is \\[a+b=c\\]')).toBe('The formula is \n$$\na+b=c\n$$\n') + }) + + it('should convert \\(...\\) to inline math format', () => { + expect(escapeBrackets('The formula is \\(a+b=c\\)')).toBe('The formula is $a+b=c$') + }) + + it('should not affect code blocks', () => { + const codeBlock = 'This is text with a code block ```const x = \\[1, 2, 3\\]```' + expect(escapeBrackets(codeBlock)).toBe(codeBlock) + }) + + it('should not affect inline code', () => { + const inlineCode = 'This is text with `const x = \\[1, 2, 3\\]` inline code' + expect(escapeBrackets(inlineCode)).toBe(inlineCode) + }) + + it('should handle multiple occurrences', () => { + const input = 'Formula 1: \\[a+b=c\\] and formula 2: \\(x+y=z\\)' + const expected = 'Formula 1: \n$$\na+b=c\n$$\n and formula 2: $x+y=z$' + expect(escapeBrackets(input)).toBe(expected) + }) + + it('should handle empty string', () => { + expect(escapeBrackets('')).toBe('') + }) + }) + + describe('extractTitle', () => { + it('should extract title from HTML string', () => { + const html = 'Page TitleContent' + expect(extractTitle(html)).toBe('Page Title') + }) + + it('should extract title with case insensitivity', () => { + const html = 'Page TitleContent' + expect(extractTitle(html)).toBe('Page Title') + }) + + it('should handle HTML without title tag', () => { + const html = 'Content' + expect(extractTitle(html)).toBeNull() + }) + + it('should handle empty title tag', () => { + const html = 'Content' + expect(extractTitle(html)).toBe('') + }) + + it('should handle malformed HTML', () => { + const html = 'Partial HTML' + expect(extractTitle(html)).toBe('Partial HTML') + }) + + it('should handle empty string', () => { + expect(extractTitle('')).toBeNull() + }) + }) + + describe('removeSvgEmptyLines', () => { + it('should remove empty lines from within SVG tags', () => { + const svg = '<svg>\n\n<circle></circle>\n\n<rect></rect>\n\n</svg>' + const expected = '<svg>\n<circle></circle>\n<rect></rect>\n</svg>' + expect(removeSvgEmptyLines(svg)).toBe(expected) + }) + + it('should handle SVG with only whitespace lines', () => { + const svg = '<svg>\n \n\t\n</svg>' + const expected = '<svg>\n</svg>' + expect(removeSvgEmptyLines(svg)).toBe(expected) + }) + + it('should handle multiple SVG tags', () => { + const content = 'Text <svg>\n\n<circle></circle>\n\n</svg> More <svg>\n\n<rect></rect>\n\n</svg>' + const expected = 'Text <svg>\n<circle></circle>\n</svg> More <svg>\n<rect></rect>\n</svg>' + expect(removeSvgEmptyLines(content)).toBe(expected) + }) + + it('should not affect content outside SVG tags', () => { + const content = 'Line 1\n\nLine 2\n\n<svg>\n<circle></circle>\n</svg>\n\nLine 3' + expect(removeSvgEmptyLines(content)).toBe(content) + }) + + it('should handle multiline SVG with attributes', () => { + const svg = '<svg width="100" height="100"\n\nviewBox="0 0 100 100">\n\n<circle></circle>\n\n</svg>' + const expected = '<svg width="100" height="100"\nviewBox="0 0 100 100">\n<circle></circle>\n</svg>' + expect(removeSvgEmptyLines(svg)).toBe(expected) + }) + + it('should handle string without SVG tags', () => { + const content = 'Text without SVG' + expect(removeSvgEmptyLines(content)).toBe(content) + }) + }) + + describe('withGeminiGrounding', () => { + it('should add citation numbers to text segments', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'Paris is the capital of France.', + metadata: { + groundingMetadata: { + groundingSupports: [ + { + segment: { text: 'Paris is the capital of France' }, + groundingChunkIndices: [0, 1] + } + ] + } + } + } as unknown as Message + + const result = withGeminiGrounding(message) + expect(result).toBe('Paris is the capital of France <sup>1</sup> <sup>2</sup>.') + }) + + it('should handle messages without groundingMetadata', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'Paris is the capital of France.' + } as unknown as Message + + const result = withGeminiGrounding(message) + expect(result).toBe('Paris is the capital of France.') + }) + + it('should handle messages with empty groundingSupports', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'Paris is the capital of France.', + metadata: { + groundingMetadata: { + groundingSupports: [] + } + } + } as unknown as Message + + const result = withGeminiGrounding(message) + expect(result).toBe('Paris is the capital of France.') + }) + + it('should handle supports without text or indices', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'Paris is the capital of France.', + metadata: { + groundingMetadata: { + groundingSupports: [ + { + segment: {}, + groundingChunkIndices: [0] + }, + { + segment: { text: 'Paris' }, + groundingChunkIndices: undefined + } + ] + } + } + } as unknown as Message + + const result = withGeminiGrounding(message) + expect(result).toBe('Paris is the capital of France.') + }) + }) + + describe('withMessageThought', () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it('should extract thought content from GLM Zero Preview model messages', () => { + // 模拟 isReasoningModel 返回 true + vi.mocked(isReasoningModel).mockReturnValue(true) + + const message = { + id: '1', + role: 'assistant' as const, + content: '###Thinking\nThis is my reasoning.\n###Response\nThis is my answer.', + modelId: 'glm-zero-preview', + model: { id: 'glm-zero-preview', name: 'GLM Zero Preview' } + } as unknown as Message + + const result = withMessageThought(message) + expect(result.reasoning_content).toBe('This is my reasoning.') + expect(result.content).toBe('This is my answer.') + }) + + it('should extract thought content from <think> tags', () => { + // 模拟 isReasoningModel 返回 true + vi.mocked(isReasoningModel).mockReturnValue(true) + + const message = { + id: '1', + role: 'assistant' as const, + content: '<think>This is my reasoning.</think>This is my answer.', + model: { id: 'some-model' } + } as unknown as Message + + const result = withMessageThought(message) + expect(result.reasoning_content).toBe('This is my reasoning.') + expect(result.content).toBe('This is my answer.') + }) + + it('should handle content with only opening <think> tag', () => { + vi.mocked(isReasoningModel).mockReturnValue(true) + + const message = { + id: '1', + role: 'assistant' as const, + content: '<think>This is all reasoning content', + model: { id: 'some-model' } + } as unknown as Message + + const result = withMessageThought(message) + expect(result.reasoning_content).toBe('This is all reasoning content') + expect(result.content).toBe('') + }) + + it('should handle content with only closing </think> tag', () => { + vi.mocked(isReasoningModel).mockReturnValue(true) + + const message = { + id: '1', + role: 'assistant' as const, + content: 'This is reasoning</think>This is my answer.', + model: { id: 'some-model' } + } as unknown as Message + + const result = withMessageThought(message) + expect(result.reasoning_content).toBe('This is reasoning') + expect(result.content).toBe('This is my answer.') + }) + + it('should not process content if model is not a reasoning model', () => { + vi.mocked(isReasoningModel).mockReturnValue(false) + + const message = { + id: '1', + role: 'assistant' as const, + content: '<think>Reasoning</think>Answer', + model: { id: 'some-model' } + } as unknown as Message + + const result = withMessageThought(message) + expect(result).toEqual(message) + expect(result.reasoning_content).toBeUndefined() + }) + + it('should not process user messages', () => { + const message = { + id: '1', + role: 'user' as const, + content: '<think>Reasoning</think>Answer' + } as unknown as Message + + const result = withMessageThought(message) + expect(result).toEqual(message) + }) + + it('should check reasoning_effort for Claude 3.7 Sonnet', () => { + vi.mocked(isReasoningModel).mockReturnValue(true) + vi.mocked(getAssistantById).mockReturnValue({ settings: { reasoning_effort: 'auto' } } as any) + + const message = { + id: '1', + role: 'assistant' as const, + content: '<think>Reasoning</think>Answer', + model: { id: 'claude-3-7-sonnet' }, + assistantId: 'assistant-1' + } as unknown as Message + + const result = withMessageThought(message) + expect(result.reasoning_content).toBe('Reasoning') + expect(result.content).toBe('Answer') + expect(getAssistantById).toHaveBeenCalledWith('assistant-1') + }) + }) + + describe('withGenerateImage', () => { + it('should extract image URLs from markdown image syntax', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'Here is an image: ![image](https://example.com/image.png)\nSome text after.', + metadata: {} + } as unknown as Message + + const result = withGenerateImage(message) + expect(result.content).toBe('Here is an image: \nSome text after.') + expect(result.metadata?.generateImage).toEqual({ + type: 'url', + images: ['https://example.com/image.png'] + }) + }) + + it('should also clean up download links', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: + 'Here is an image: ![image](https://example.com/image.png)\nYou can [download it](https://example.com/download)', + metadata: {} + } as unknown as Message + + const result = withGenerateImage(message) + expect(result.content).toBe('Here is an image:') + expect(result.metadata?.generateImage).toEqual({ + type: 'url', + images: ['https://example.com/image.png'] + }) + }) + + it('should handle messages without image markdown', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'This is just text without any images.', + metadata: {} + } as unknown as Message + + const result = withGenerateImage(message) + expect(result).toEqual(message) + }) + + it('should handle image markdown with title attribute', () => { + const message = { + id: '1', + role: 'assistant' as const, + content: 'Here is an image: ![alt text](https://example.com/image.png "Image Title")', + metadata: {} + } as unknown as Message + + const result = withGenerateImage(message) + expect(result.content).toBe('Here is an image:') + expect(result.metadata?.generateImage).toEqual({ + type: 'url', + images: ['https://example.com/image.png'] + }) + }) + }) + + describe('addImageFileToContents', () => { + it('should add image files to the assistant message', () => { + const messages = [ + { id: '1', role: 'user' as const, content: 'Generate an image' }, + { + id: '2', + role: 'assistant' as const, + content: 'Here is your image.', + metadata: { + generateImage: { + images: ['image1.png', 'image2.png'] + } + } + } + ] as unknown as Message[] + + const result = addImageFileToContents(messages) + expect(result[1].images).toEqual(['image1.png', 'image2.png']) + }) + + it('should not modify messages if no assistant message with generateImage', () => { + const messages = [ + { id: '1', role: 'user' as const, content: 'Hello' }, + { id: '2', role: 'assistant' as const, content: 'Hi there', metadata: {} } + ] as unknown as Message[] + + const result = addImageFileToContents(messages) + expect(result).toEqual(messages) + }) + + it('should handle messages without metadata', () => { + const messages = [ + { id: '1', role: 'user' as const, content: 'Hello' }, + { id: '2', role: 'assistant' as const, content: 'Hi there' } + ] as unknown as Message[] + + const result = addImageFileToContents(messages) + expect(result).toEqual(messages) + }) + + it('should update only the last assistant message', () => { + const messages = [ + { + id: '1', + role: 'assistant' as const, + content: 'First response', + metadata: { + generateImage: { + images: ['old.png'] + } + } + }, + { id: '2', role: 'user' as const, content: 'Another request' }, + { + id: '3', + role: 'assistant' as const, + content: 'New response', + metadata: { + generateImage: { + images: ['new.png'] + } + } + } + ] as unknown as Message[] + + const result = addImageFileToContents(messages) + expect(result[0].images).toBeUndefined() + expect(result[2].images).toEqual(['new.png']) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/image.test.ts b/src/renderer/src/utils/__tests__/image.test.ts new file mode 100644 index 00000000..fc658be5 --- /dev/null +++ b/src/renderer/src/utils/__tests__/image.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { + captureDiv, + captureScrollableDiv, + captureScrollableDivAsBlob, + captureScrollableDivAsDataURL, + compressImage, + convertToBase64 +} from '../image' + +// mock 依赖 +vi.mock('browser-image-compression', () => ({ + default: vi.fn(() => Promise.resolve(new File(['compressed'], 'compressed.png', { type: 'image/png' }))) +})) +vi.mock('html-to-image', () => ({ + toCanvas: vi.fn(() => + Promise.resolve({ + toDataURL: vi.fn(() => 'data:image/png;base64,xxx'), + toBlob: vi.fn((cb) => cb(new Blob(['blob'], { type: 'image/png' }))) + }) + ) +})) + +// mock window.message +beforeEach(() => { + window.message = { + error: vi.fn() + } as any +}) + +describe('utils/image', () => { + describe('convertToBase64', () => { + it('should convert file to base64 string', async () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }) + const result = await convertToBase64(file) + expect(typeof result).toBe('string') + expect(result).toMatch(/^data:/) + }) + }) + + describe('compressImage', () => { + it('should compress image file', async () => { + const file = new File(['img'], 'img.png', { type: 'image/png' }) + const result = await compressImage(file) + expect(result).toBeInstanceOf(File) + expect(result.name).toBe('compressed.png') + }) + }) + + describe('captureDiv', () => { + it('should return image data url when divRef.current exists', async () => { + const ref = { current: document.createElement('div') } as React.RefObject<HTMLDivElement> + const result = await captureDiv(ref) + expect(result).toMatch(/^data:image\/png;base64/) + }) + + it('should return undefined when divRef.current is null', async () => { + const ref = { current: null } as unknown as React.RefObject<HTMLDivElement> + const result = await captureDiv(ref) + expect(result).toBeUndefined() + }) + }) + + describe('captureScrollableDiv', () => { + it('should return canvas when divRef.current exists', async () => { + const div = document.createElement('div') + Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true }) + Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true }) + const ref = { current: div } as React.RefObject<HTMLDivElement> + const result = await captureScrollableDiv(ref) + expect(result).toBeTruthy() + expect(typeof (result as HTMLCanvasElement).toDataURL).toBe('function') + }) + + it('should return undefined when divRef.current is null', async () => { + const ref = { current: null } as unknown as React.RefObject<HTMLDivElement> + const result = await captureScrollableDiv(ref) + expect(result).toBeUndefined() + }) + + it('should reject if dimension too large', async () => { + const div = document.createElement('div') + Object.defineProperty(div, 'scrollWidth', { value: 40000, configurable: true }) + Object.defineProperty(div, 'scrollHeight', { value: 40000, configurable: true }) + const ref = { current: div } as React.RefObject<HTMLDivElement> + await expect(captureScrollableDiv(ref)).rejects.toBeUndefined() + expect(window.message.error).toHaveBeenCalled() + }) + }) + + describe('captureScrollableDivAsDataURL', () => { + it('should return data url when canvas exists', async () => { + const div = document.createElement('div') + Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true }) + Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true }) + const ref = { current: div } as React.RefObject<HTMLDivElement> + const result = await captureScrollableDivAsDataURL(ref) + expect(result).toMatch(/^data:image\/png;base64/) + }) + + it('should return undefined when canvas is undefined', async () => { + const ref = { current: null } as unknown as React.RefObject<HTMLDivElement> + const result = await captureScrollableDivAsDataURL(ref) + expect(result).toBeUndefined() + }) + }) + + describe('captureScrollableDivAsBlob', () => { + it('should call func with blob when canvas exists', async () => { + const div = document.createElement('div') + Object.defineProperty(div, 'scrollWidth', { value: 100, configurable: true }) + Object.defineProperty(div, 'scrollHeight', { value: 100, configurable: true }) + const ref = { current: div } as React.RefObject<HTMLDivElement> + const func = vi.fn() + await captureScrollableDivAsBlob(ref, func) + expect(func).toHaveBeenCalled() + expect(func.mock.calls[0][0]).toBeInstanceOf(Blob) + }) + + it('should not call func when canvas is undefined', async () => { + const ref = { current: null } as unknown as React.RefObject<HTMLDivElement> + const func = vi.fn() + await captureScrollableDivAsBlob(ref, func) + expect(func).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/index.test.ts b/src/renderer/src/utils/__tests__/index.test.ts new file mode 100644 index 00000000..c26f4804 --- /dev/null +++ b/src/renderer/src/utils/__tests__/index.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { delay, runAsyncFunction } from '../index' + +describe('Unclassified Utils', () => { + describe('runAsyncFunction', () => { + it('should execute async function', async () => { + // 验证异步函数被执行 + let called = false + await runAsyncFunction(async () => { + called = true + }) + expect(called).toBe(true) + }) + + it('should throw error if async function fails', async () => { + // 验证异步函数抛出错误 + await expect( + runAsyncFunction(async () => { + throw new Error('Test error') + }) + ).rejects.toThrow('Test error') + }) + }) + + describe('delay', () => { + it('should resolve after specified seconds', async () => { + // 验证指定时间后返回 + const start = Date.now() + await delay(0.01) + const end = Date.now() + expect(end - start).toBeGreaterThanOrEqual(10) + }) + + it('should resolve immediately for zero delay', async () => { + // 验证零延迟立即返回 + const start = Date.now() + await delay(0) + const end = Date.now() + expect(end - start).toBeLessThan(100) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/json.test.ts b/src/renderer/src/utils/__tests__/json.test.ts new file mode 100644 index 00000000..22007140 --- /dev/null +++ b/src/renderer/src/utils/__tests__/json.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' + +import { isJSON, parseJSON } from '../index' + +describe('json', () => { + describe('isJSON', () => { + it('should return true for valid JSON string', () => { + // 验证有效 JSON 字符串 + expect(isJSON('{"key": "value"}')).toBe(true) + }) + + it('should return false for empty string', () => { + // 验证空字符串 + expect(isJSON('')).toBe(false) + }) + + it('should return false for invalid JSON string', () => { + // 验证无效 JSON 字符串 + expect(isJSON('{invalid json}')).toBe(false) + }) + + it('should return false for non-string input', () => { + // 验证非字符串输入 + expect(isJSON(123)).toBe(false) + expect(isJSON({})).toBe(false) + expect(isJSON(null)).toBe(false) + expect(isJSON(undefined)).toBe(false) + }) + }) + + describe('parseJSON', () => { + it('should parse valid JSON string to object', () => { + // 验证有效 JSON 字符串解析 + const result = parseJSON('{"key": "value"}') + expect(result).toEqual({ key: 'value' }) + }) + + it('should return null for invalid JSON string', () => { + // 验证无效 JSON 字符串返回 null + const result = parseJSON('{invalid json}') + expect(result).toBe(null) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/linkConverter.test.ts b/src/renderer/src/utils/__tests__/linkConverter.test.ts new file mode 100644 index 00000000..48d46140 --- /dev/null +++ b/src/renderer/src/utils/__tests__/linkConverter.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest' + +import { + cleanLinkCommas, + completeLinks, + convertLinks, + convertLinksToHunyuan, + convertLinksToOpenRouter, + convertLinksToZhipu, + extractUrlsFromMarkdown +} from '../linkConverter' + +describe('linkConverter', () => { + describe('convertLinksToZhipu', () => { + it('should correctly convert complete [ref_N] format', () => { + const input = '这里有一个参考文献 [ref_1] 和另一个 [ref_2]' + const result = convertLinksToZhipu(input, true) + expect(result).toBe('这里有一个参考文献 [<sup>1</sup>]() 和另一个 [<sup>2</sup>]()') + }) + + it('should handle chunked input and preserve incomplete link patterns', () => { + // 第一个块包含未完成的模式 + const chunk1 = '这是第一部分 [ref' + const result1 = convertLinksToZhipu(chunk1, true) + expect(result1).toBe('这是第一部分 ') + + // 第二个块完成该模式 + const chunk2 = '_1] 这是剩下的部分' + const result2 = convertLinksToZhipu(chunk2, false) + expect(result2).toBe('[<sup>1</sup>]() 这是剩下的部分') + }) + + it('should clear buffer when resetting counter', () => { + // 先进行一次转换不重置 + const input1 = '第一次输入 [ref_1]' + convertLinksToZhipu(input1, false) + + // 然后重置并进行新的转换 + const input2 = '新的输入 [ref_2]' + const result = convertLinksToZhipu(input2, true) + expect(result).toBe('新的输入 [<sup>2</sup>]()') + }) + }) + + describe('convertLinksToHunyuan', () => { + it('should correctly convert [N](@ref) format to links with URLs', () => { + const webSearch = [{ url: 'https://example.com/1' }, { url: 'https://example.com/2' }] + const input = '这里有单个引用 [1](@ref) 和多个引用 [2](@ref)' + const result = convertLinksToHunyuan(input, webSearch, true) + expect(result).toBe( + '这里有单个引用 [<sup>1</sup>](https://example.com/1) 和多个引用 [<sup>2</sup>](https://example.com/2)' + ) + }) + + it('should correctly handle comma-separated multiple references', () => { + const webSearch = [ + { url: 'https://example.com/1' }, + { url: 'https://example.com/2' }, + { url: 'https://example.com/3' } + ] + const input = '这里有多个引用 [1, 2, 3](@ref)' + const result = convertLinksToHunyuan(input, webSearch, true) + expect(result).toBe( + '这里有多个引用 [<sup>1</sup>](https://example.com/1)[<sup>2</sup>](https://example.com/2)[<sup>3</sup>](https://example.com/3)' + ) + }) + + it('should handle non-existent reference indices', () => { + const webSearch = [{ url: 'https://example.com/1' }] + const input = '这里有一个超出范围的引用 [2](@ref)' + const result = convertLinksToHunyuan(input, webSearch, true) + expect(result).toBe('这里有一个超出范围的引用 [<sup>2</sup>](@ref)') + }) + + it('should handle incomplete reference formats in chunked input', () => { + const webSearch = [{ url: 'https://example.com/1' }] + // 第一个块包含未完成的模式 + const chunk1 = '这是第一部分 [' + const result1 = convertLinksToHunyuan(chunk1, webSearch, true) + expect(result1).toBe('这是第一部分 ') + + // 第二个块完成该模式 + const chunk2 = '1](@ref) 这是剩下的部分' + const result2 = convertLinksToHunyuan(chunk2, webSearch, false) + expect(result2).toBe('[<sup>1</sup>](https://example.com/1) 这是剩下的部分') + }) + }) + + describe('convertLinks', () => { + it('should convert links with domain-like text to numbered links', () => { + const input = '查看这个网站 [example.com](https://example.com)' + const result = convertLinks(input, true) + expect(result).toBe('查看这个网站 [<sup>1</sup>](https://example.com)') + }) + + it('should handle parenthesized link format ([host](url))', () => { + const input = '这里有链接 ([example.com](https://example.com))' + const result = convertLinks(input, true) + expect(result).toBe('这里有链接 [<sup>1</sup>](https://example.com)') + }) + + it('should preserve non-domain link text', () => { + const input = '点击[这里](https://example.com)查看更多' + const result = convertLinks(input, true) + expect(result).toBe('点击这里[<sup>1</sup>](https://example.com)查看更多') + }) + + it('should use the same counter for duplicate URLs', () => { + const input = + '第一个链接 [example.com](https://example.com) 和第二个相同链接 [subdomain.example.com](https://example.com)' + const result = convertLinks(input, true) + expect(result).toBe( + '第一个链接 [<sup>1</sup>](https://example.com) 和第二个相同链接 [<sup>1</sup>](https://example.com)' + ) + }) + + it('should correctly convert links in Zhipu mode', () => { + const input = '这里是引用 [ref_1]' + const result = convertLinks(input, true, true) + expect(result).toBe('这里是引用 [<sup>1</sup>]()') + }) + + it('should handle incomplete links in chunked input', () => { + // 第一个块包含未完成的链接 + const chunk1 = '这是链接 [' + const result1 = convertLinks(chunk1, true) + expect(result1).toBe('这是链接 ') + + // 第二个块完成链接 + const chunk2 = 'example.com](https://example.com)' + const result2 = convertLinks(chunk2, false) + expect(result2).toBe('[<sup>1</sup>](https://example.com)') + }) + }) + + describe('convertLinksToOpenRouter', () => { + it('should only convert links with domain-like text', () => { + const input = '网站 [example.com](https://example.com) 和 [点击这里](https://other.com)' + const result = convertLinksToOpenRouter(input, true) + expect(result).toBe('网站 [<sup>1</sup>](https://example.com) 和 [点击这里](https://other.com)') + }) + + it('should use the same counter for duplicate URLs', () => { + const input = '两个相同的链接 [example.com](https://example.com) 和 [example.org](https://example.com)' + const result = convertLinksToOpenRouter(input, true) + expect(result).toBe('两个相同的链接 [<sup>1</sup>](https://example.com) 和 [<sup>1</sup>](https://example.com)') + }) + + it('should handle incomplete links in chunked input', () => { + // 第一个块包含未完成的链接 + const chunk1 = '这是域名链接 [' + const result1 = convertLinksToOpenRouter(chunk1, true) + expect(result1).toBe('这是域名链接 ') + + // 第二个块完成链接 + const chunk2 = 'example.com](https://example.com)' + const result2 = convertLinksToOpenRouter(chunk2, false) + expect(result2).toBe('[<sup>1</sup>](https://example.com)') + }) + }) + + describe('completeLinks', () => { + it('should complete empty links with webSearch data', () => { + const webSearch = [{ link: 'https://example.com/1' }, { link: 'https://example.com/2' }] + const input = '参考 [<sup>1</sup>]() 和 [<sup>2</sup>]()' + const result = completeLinks(input, webSearch) + expect(result).toBe('参考 [<sup>1</sup>](https://example.com/1) 和 [<sup>2</sup>](https://example.com/2)') + }) + + it('should preserve link format when URL not found', () => { + const webSearch = [{ link: 'https://example.com/1' }] + const input = '参考 [<sup>1</sup>]() 和 [<sup>2</sup>]()' + const result = completeLinks(input, webSearch) + expect(result).toBe('参考 [<sup>1</sup>](https://example.com/1) 和 [<sup>2</sup>]()') + }) + + it('should handle empty webSearch array', () => { + const webSearch: any[] = [] + const input = '参考 [<sup>1</sup>]() 和 [<sup>2</sup>]()' + const result = completeLinks(input, webSearch) + expect(result).toBe('参考 [<sup>1</sup>]() 和 [<sup>2</sup>]()') + }) + }) + + describe('extractUrlsFromMarkdown', () => { + it('should extract URLs from all link formats', () => { + const input = + '这里有普通链接 [文本](https://example.com) 和编号链接 [<sup>1</sup>](https://other.com) 以及括号链接 ([域名](https://third.com))' + const result = extractUrlsFromMarkdown(input) + expect(result).toEqual(['https://example.com', 'https://other.com', 'https://third.com']) + }) + + it('should deduplicate URLs', () => { + const input = '重复链接 [链接1](https://example.com) 和 [链接2](https://example.com)' + const result = extractUrlsFromMarkdown(input) + expect(result).toEqual(['https://example.com']) + }) + + it('should filter invalid URLs', () => { + const input = '有效链接 [链接](https://example.com) 和无效链接 [链接](invalid-url)' + const result = extractUrlsFromMarkdown(input) + expect(result.length).toBe(1) + expect(result[0]).toBe('https://example.com') + }) + + it('should handle empty string', () => { + const input = '' + const result = extractUrlsFromMarkdown(input) + expect(result).toEqual([]) + }) + }) + + describe('cleanLinkCommas', () => { + it('should remove commas between links', () => { + const input = '[链接1](https://example.com),[链接2](https://other.com)' + const result = cleanLinkCommas(input) + expect(result).toBe('[链接1](https://example.com)[链接2](https://other.com)') + }) + + it('should handle commas with spaces between links', () => { + const input = '[链接1](https://example.com) , [链接2](https://other.com)' + const result = cleanLinkCommas(input) + expect(result).toBe('[链接1](https://example.com)[链接2](https://other.com)') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/markdown.test.ts b/src/renderer/src/utils/__tests__/markdown.test.ts new file mode 100644 index 00000000..db342f7e --- /dev/null +++ b/src/renderer/src/utils/__tests__/markdown.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' + +import { + convertMathFormula, + findCitationInChildren, + MARKDOWN_ALLOWED_TAGS, + removeTrailingDoubleSpaces, + sanitizeSchema +} from '../markdown' + +describe('markdown', () => { + describe('findCitationInChildren', () => { + it('returns null when children is null or undefined', () => { + expect(findCitationInChildren(null)).toBeNull() + expect(findCitationInChildren(undefined)).toBeNull() + }) + + it('finds citation in direct child element', () => { + const children = [{ props: { 'data-citation': 'test-citation' } }] + expect(findCitationInChildren(children)).toBe('test-citation') + }) + + it('finds citation in nested child element', () => { + const children = [ + { + props: { + children: [{ props: { 'data-citation': 'nested-citation' } }] + } + } + ] + expect(findCitationInChildren(children)).toBe('nested-citation') + }) + + it('returns null when no citation is found', () => { + const children = [{ props: { foo: 'bar' } }, { props: { children: [{ props: { baz: 'qux' } }] } }] + expect(findCitationInChildren(children)).toBeNull() + }) + + it('handles single child object (non-array)', () => { + const child = { props: { 'data-citation': 'single-citation' } } + expect(findCitationInChildren(child)).toBe('single-citation') + }) + + it('handles deeply nested structures', () => { + const children = [ + { + props: { + children: [ + { + props: { + children: [ + { + props: { + children: { + props: { 'data-citation': 'deep-citation' } + } + } + } + ] + } + } + ] + } + } + ] + expect(findCitationInChildren(children)).toBe('deep-citation') + }) + + it('handles non-object children gracefully', () => { + const children = ['text node', 123, { props: { 'data-citation': 'mixed-citation' } }] + expect(findCitationInChildren(children)).toBe('mixed-citation') + }) + }) + + describe('markdown configuration constants', () => { + it('MARKDOWN_ALLOWED_TAGS contains expected tags', () => { + expect(MARKDOWN_ALLOWED_TAGS).toContain('p') + expect(MARKDOWN_ALLOWED_TAGS).toContain('div') + expect(MARKDOWN_ALLOWED_TAGS).toContain('code') + expect(MARKDOWN_ALLOWED_TAGS).toContain('svg') + expect(MARKDOWN_ALLOWED_TAGS.length).toBeGreaterThan(10) + }) + + it('sanitizeSchema contains proper configuration', () => { + expect(sanitizeSchema.tagNames).toBe(MARKDOWN_ALLOWED_TAGS) + expect(sanitizeSchema.attributes).toHaveProperty('*') + expect(sanitizeSchema.attributes).toHaveProperty('svg') + expect(sanitizeSchema.attributes).toHaveProperty('a') + }) + + it('sanitizeSchema matches snapshot', () => { + expect(sanitizeSchema).toMatchSnapshot() + }) + }) + + describe('convertMathFormula', () => { + it('should convert LaTeX block delimiters to $$$$', () => { + // 验证将 LaTeX 块分隔符转换为 $$$$ + const input = 'Some text \\[math formula\\] more text' + const result = convertMathFormula(input) + expect(result).toBe('Some text $$math formula$$ more text') + }) + + it('should convert LaTeX inline delimiters to $$', () => { + // 验证将 LaTeX 内联分隔符转换为 $$ + const input = 'Some text \\(inline math\\) more text' + const result = convertMathFormula(input) + expect(result).toBe('Some text $inline math$ more text') + }) + + it('should handle multiple delimiters in input', () => { + // 验证处理输入中的多个分隔符 + const input = 'Text \\[block1\\] and \\(inline\\) and \\[block2\\]' + const result = convertMathFormula(input) + expect(result).toBe('Text $$block1$$ and $inline$ and $$block2$$') + }) + + it('should return input unchanged if no delimiters', () => { + // 验证没有分隔符时返回原始输入 + const input = 'Some text without math' + const result = convertMathFormula(input) + expect(result).toBe('Some text without math') + }) + + it('should return input if null or empty', () => { + // 验证空输入或 null 输入时返回原值 + expect(convertMathFormula('')).toBe('') + expect(convertMathFormula(null)).toBe(null) + }) + }) + + describe('removeTrailingDoubleSpaces', () => { + it('should remove trailing double spaces from each line', () => { + // 验证移除每行末尾的两个空格 + const input = 'Line one \nLine two \nLine three' + const result = removeTrailingDoubleSpaces(input) + expect(result).toBe('Line one\nLine two\nLine three') + }) + + it('should handle single line with trailing double spaces', () => { + // 验证处理单行末尾的两个空格 + const input = 'Single line ' + const result = removeTrailingDoubleSpaces(input) + expect(result).toBe('Single line') + }) + + it('should return unchanged if no trailing double spaces', () => { + // 验证没有末尾两个空格时返回原始输入 + const input = 'Line one\nLine two \nLine three' + const result = removeTrailingDoubleSpaces(input) + expect(result).toBe('Line one\nLine two \nLine three') + }) + + it('should handle empty string', () => { + // 验证处理空字符串 + const input = '' + const result = removeTrailingDoubleSpaces(input) + expect(result).toBe('') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/naming.test.ts b/src/renderer/src/utils/__tests__/naming.test.ts new file mode 100644 index 00000000..f6814c00 --- /dev/null +++ b/src/renderer/src/utils/__tests__/naming.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest' + +import { + firstLetter, + generateColorFromChar, + getBriefInfo, + getDefaultGroupName, + getFirstCharacter, + getLeadingEmoji, + isEmoji, + removeLeadingEmoji, + removeSpecialCharactersForTopicName +} from '../naming' + +describe('naming', () => { + describe('firstLetter', () => { + it('should return first letter of string', () => { + // 验证普通字符串的第一个字符 + expect(firstLetter('Hello')).toBe('H') + }) + + it('should return first emoji of string', () => { + // 验证包含表情符号的字符串 + expect(firstLetter('😊Hello')).toBe('😊') + }) + + it('should return empty string for empty input', () => { + // 验证空字符串 + expect(firstLetter('')).toBe('') + }) + }) + + describe('removeLeadingEmoji', () => { + it('should remove leading emoji from string', () => { + // 验证移除开头的表情符号 + expect(removeLeadingEmoji('😊Hello')).toBe('Hello') + }) + + it('should return original string if no leading emoji', () => { + // 验证没有表情符号的字符串 + expect(removeLeadingEmoji('Hello')).toBe('Hello') + }) + + it('should return empty string if only emojis', () => { + // 验证全表情符号字符串 + expect(removeLeadingEmoji('😊😊')).toBe('') + }) + }) + + describe('getLeadingEmoji', () => { + it('should return leading emoji from string', () => { + // 验证提取开头的表情符号 + expect(getLeadingEmoji('😊Hello')).toBe('😊') + }) + + it('should return empty string if no leading emoji', () => { + // 验证没有表情符号的字符串 + expect(getLeadingEmoji('Hello')).toBe('') + }) + + it('should return all emojis if only emojis', () => { + // 验证全表情符号字符串 + expect(getLeadingEmoji('😊😊')).toBe('😊😊') + }) + }) + + describe('isEmoji', () => { + it('should return true for pure emoji string', () => { + // 验证纯表情符号字符串返回 true + expect(isEmoji('😊')).toBe(true) + }) + + it('should return false for mixed emoji and text string', () => { + // 验证包含表情符号和文本的字符串返回 false + expect(isEmoji('😊Hello')).toBe(false) + }) + + it('should return false for non-emoji string', () => { + // 验证非表情符号字符串返回 false + expect(isEmoji('Hello')).toBe(false) + }) + + it('should return false for data URI or URL', () => { + // 验证 data URI 或 URL 字符串返回 false + expect(isEmoji('data:image/png;base64,...')).toBe(false) + expect(isEmoji('https://example.com')).toBe(false) + }) + }) + + describe('removeSpecialCharactersForTopicName', () => { + it('should replace newlines with space for topic name', () => { + // 验证移除换行符并转换为空格 + expect(removeSpecialCharactersForTopicName('Hello\nWorld')).toBe('Hello World') + }) + + it('should return original string if no newlines', () => { + // 验证没有换行符的字符串 + expect(removeSpecialCharactersForTopicName('Hello World')).toBe('Hello World') + }) + + it('should return empty string for empty input', () => { + // 验证空字符串 + expect(removeSpecialCharactersForTopicName('')).toBe('') + }) + }) + + describe('getDefaultGroupName', () => { + it('should extract group name from ID with slash', () => { + // 验证从包含斜杠的 ID 中提取组名 + expect(getDefaultGroupName('group/model')).toBe('group') + }) + + it('should extract group name from ID with colon', () => { + // 验证从包含冒号的 ID 中提取组名 + expect(getDefaultGroupName('group:model')).toBe('group') + }) + + it('should extract group name from ID with hyphen', () => { + // 验证从包含连字符的 ID 中提取组名 + expect(getDefaultGroupName('group-subgroup-model')).toBe('group-subgroup') + }) + + it('should return original ID if no separators', () => { + // 验证没有分隔符时返回原始 ID + expect(getDefaultGroupName('group')).toBe('group') + }) + }) + + describe('generateColorFromChar', () => { + it('should generate a valid hex color code', () => { + // 验证生成有效的十六进制颜色代码 + const result = generateColorFromChar('A') + expect(result).toMatch(/^#[0-9a-fA-F]{6}$/) + }) + + it('should generate consistent color for same input', () => { + // 验证相同输入生成一致的颜色 + const result1 = generateColorFromChar('A') + const result2 = generateColorFromChar('A') + expect(result1).toBe(result2) + }) + + it('should generate different colors for different inputs', () => { + // 验证不同输入生成不同的颜色 + const result1 = generateColorFromChar('A') + const result2 = generateColorFromChar('B') + expect(result1).not.toBe(result2) + }) + }) + + describe('getFirstCharacter', () => { + it('should return first character of string', () => { + // 验证返回字符串的第一个字符 + expect(getFirstCharacter('Hello')).toBe('H') + }) + + it('should return empty string for empty input', () => { + // 验证空字符串返回空字符串 + expect(getFirstCharacter('')).toBe('') + }) + + it('should handle special characters and emojis', () => { + // 验证处理特殊字符和表情符号 + expect(getFirstCharacter('😊Hello')).toBe('😊') + }) + }) + + describe('getBriefInfo', () => { + it('should return original text if under max length', () => { + // 验证文本长度小于最大长度时返回原始文本 + const text = 'Short text' + expect(getBriefInfo(text, 20)).toBe('Short text') + }) + + it('should truncate text at word boundary with ellipsis', () => { + // 验证在单词边界处截断文本并添加省略号 + const text = 'This is a long text that needs truncation' + const result = getBriefInfo(text, 10) + expect(result).toBe('This is a...') + }) + + it('should handle empty lines by removing them', () => { + // 验证移除空行 + const text = 'Line1\n\nLine2' + expect(getBriefInfo(text, 20)).toBe('Line1\nLine2') + }) + + it('should handle custom max length', () => { + // 验证自定义最大长度 + const text = 'This is a long text' + expect(getBriefInfo(text, 5)).toBe('This...') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/prompt.test.ts b/src/renderer/src/utils/__tests__/prompt.test.ts new file mode 100644 index 00000000..fa77124d --- /dev/null +++ b/src/renderer/src/utils/__tests__/prompt.test.ts @@ -0,0 +1,71 @@ +import { MCPTool } from '@renderer/types' +import { describe, expect, it } from 'vitest' + +import { AvailableTools, buildSystemPrompt } from '../prompt' + +describe('prompt', () => { + // 辅助函数:创建符合 MCPTool 类型的工具对象 + const createMcpTool = (id: string, description: string, inputSchema: any): MCPTool => ({ + id, + description, + inputSchema, + serverId: 'test-server-id', + serverName: 'test-server', + name: id + }) + + describe('AvailableTools', () => { + it('should generate XML format for tools', () => { + const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] + const result = AvailableTools(tools) + + expect(result).toContain('<tools>') + expect(result).toContain('</tools>') + expect(result).toContain('<tool>') + expect(result).toContain('test-tool') + expect(result).toContain('Test tool description') + expect(result).toContain('{"type":"object"}') + }) + + it('should handle empty tools array', () => { + const result = AvailableTools([]) + + expect(result).toContain('<tools>') + expect(result).toContain('</tools>') + expect(result).not.toContain('<tool>') + }) + }) + + describe('buildSystemPrompt', () => { + it('should build prompt with tools', () => { + const userPrompt = 'Custom user system prompt' + const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] + const result = buildSystemPrompt(userPrompt, tools) + + expect(result).toContain(userPrompt) + expect(result).toContain('test-tool') + expect(result).toContain('Test tool description') + }) + + it('should return user prompt without tools', () => { + const userPrompt = 'Custom user system prompt' + const result = buildSystemPrompt(userPrompt, []) + + expect(result).toBe(userPrompt) + }) + + it('should handle null or undefined user prompt', () => { + const tools = [createMcpTool('test-tool', 'Test tool description', { type: 'object' })] + + // 测试 userPrompt 为 null 的情况 + const resultNull = buildSystemPrompt(null as any, tools) + expect(resultNull).toBeDefined() + expect(resultNull).not.toContain('{{ USER_SYSTEM_PROMPT }}') + + // 测试 userPrompt 为 undefined 的情况 + const resultUndefined = buildSystemPrompt(undefined as any, tools) + expect(resultUndefined).toBeDefined() + expect(resultUndefined).not.toContain('{{ USER_SYSTEM_PROMPT }}') + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/sort.test.ts b/src/renderer/src/utils/__tests__/sort.test.ts new file mode 100644 index 00000000..64aa26fe --- /dev/null +++ b/src/renderer/src/utils/__tests__/sort.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' + +import { droppableReorder, sortByEnglishFirst } from '../sort' + +describe('sort', () => { + describe('droppableReorder', () => { + it('should reorder elements by moving single element forward', () => { + const list = [1, 2, 3, 4, 5] + const result = droppableReorder(list, 0, 2) + expect(result).toEqual([2, 3, 1, 4, 5]) + }) + + it('should reorder elements by moving single element backward', () => { + const list = [1, 2, 3, 4, 5] + const result = droppableReorder(list, 4, 1) + expect(result).toEqual([1, 5, 2, 3, 4]) + }) + + it('should support multi-element drag reordering while preserving group order', () => { + const list = [1, 2, 3, 4, 5] + const result = droppableReorder(list, 1, 3, 2) + // 移动 [2,3] 到 '4' 后面,结果应为 [1, 4, 2, 3, 5] + expect(result).toEqual([1, 4, 2, 3, 5]) + }) + + it('should handle complex multi-element reordering while preserving group order', () => { + const list = ['a', 'b', 'c', 'd', 'e', 'f', 'g'] + const result = droppableReorder(list, 2, 5, 3) + // 移动 [c,d,e] 到 'f' 后面,结果应为 ['a', 'b', 'f', 'c', 'd', 'e', 'g'] + expect(result).toEqual(['a', 'b', 'f', 'c', 'd', 'e', 'g']) + }) + + it('should maintain internal order of multi-element group when moving forward', () => { + const list = [1, 2, 3, 4, 5, 6, 7] + const result = droppableReorder(list, 1, 5, 3) + // 移动 [2,3,4] 到 '6' 后面,结果应为 [1,5,6,2,3,4,7] + expect(result).toEqual([1, 5, 6, 2, 3, 4, 7]) + }) + + it('should maintain internal order of multi-element group when moving backward', () => { + const list = [1, 2, 3, 4, 5, 6, 7] + const result = droppableReorder(list, 4, 1, 3) + // 移动 [5,6,7] 到 '2' 前面,结果应为 [1,5,6,7,2,3,4] + expect(result).toEqual([1, 5, 6, 7, 2, 3, 4]) + }) + + it('should handle empty list', () => { + const list: number[] = [] + const result = droppableReorder(list, 0, 0) + expect(result).toEqual([]) + }) + + it('should not modify original list', () => { + const list = [1, 2, 3, 4, 5] + const originalList = [...list] + droppableReorder(list, 0, 2) + expect(list).toEqual(originalList) + }) + + it('should handle string list', () => { + const list = ['a', 'b', 'c', 'd'] + const result = droppableReorder(list, 0, 2) + expect(result).toEqual(['b', 'c', 'a', 'd']) + }) + + it('should handle object list', () => { + const list = [{ id: 1 }, { id: 2 }, { id: 3 }] + const result = droppableReorder(list, 0, 2) + expect(result).toEqual([{ id: 2 }, { id: 3 }, { id: 1 }]) + }) + }) + + describe('sortByEnglishFirst', () => { + it('should place English characters before non-English', () => { + expect(sortByEnglishFirst('apple', '苹果')).toBe(-1) + expect(sortByEnglishFirst('苹果', 'apple')).toBe(1) + }) + + it('should sort two English strings alphabetically', () => { + const result = sortByEnglishFirst('banana', 'apple') + expect(result).toBeGreaterThan(0) // 'banana' comes after 'apple' + }) + + it('should sort two non-English strings using localeCompare', () => { + const result = sortByEnglishFirst('苹果', '香蕉') + // 由于依赖localeCompare,具体结果取决于当前环境,但应该是一致的 + expect(typeof result).toBe('number') + }) + + it('should handle empty strings', () => { + expect(sortByEnglishFirst('', 'a')).toBeGreaterThan(0) // 空字符串不是英文字母开头 + expect(sortByEnglishFirst('a', '')).toBeLessThan(0) + }) + + it('should handle strings starting with numbers', () => { + expect(sortByEnglishFirst('1apple', 'apple')).toBeGreaterThan(0) // 数字不算英文字母 + expect(sortByEnglishFirst('apple', '1apple')).toBeLessThan(0) + }) + + it('should handle uppercase and lowercase English letters', () => { + expect(sortByEnglishFirst('Apple', 'banana')).toBeLessThan(0) // 大写字母也是英文 + expect(sortByEnglishFirst('apple', 'Banana')).toBeLessThan(0) // 按字母顺序排序 + }) + + it('should handle special characters', () => { + expect(sortByEnglishFirst('#apple', 'banana')).toBeGreaterThan(0) // 特殊字符不算英文字母 + expect(sortByEnglishFirst('apple', '#banana')).toBeLessThan(0) + }) + }) +}) diff --git a/src/renderer/src/utils/__tests__/style.test.ts b/src/renderer/src/utils/__tests__/style.test.ts new file mode 100644 index 00000000..af854901 --- /dev/null +++ b/src/renderer/src/utils/__tests__/style.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { classNames } from '../style' + +describe('style', () => { + describe('classNames', () => { + it('should handle string arguments', () => { + expect(classNames('foo', 'bar')).toBe('foo bar') + expect(classNames('foo bar', 'baz')).toBe('foo bar baz') + expect(classNames('foo', '')).toBe('foo') + }) + + it('should handle number arguments', () => { + expect(classNames(1, 2)).toBe('1 2') + expect(classNames('foo', 123)).toBe('foo 123') + }) + + it('should filter out falsy values', () => { + expect(classNames('foo', null, 'bar')).toBe('foo bar') + expect(classNames('foo', undefined, 'bar')).toBe('foo bar') + expect(classNames('foo', false, 'bar')).toBe('foo bar') + expect(classNames('foo', true, 'bar')).toBe('foo bar') + expect(classNames('foo', 0, 'bar')).toBe('foo bar') // 数字 0 被视为假值,被过滤掉 + }) + + it('should handle object arguments', () => { + expect(classNames({ foo: true, bar: false })).toBe('foo') + expect(classNames({ foo: true, bar: true })).toBe('foo bar') + expect(classNames({ 'foo-bar': true })).toBe('foo-bar') + expect(classNames({ foo: 1, bar: 0 })).toBe('foo') + expect(classNames({ foo: {}, bar: [] })).toBe('foo bar') // non-empty objects/arrays are truthy + expect(classNames({ foo: '', bar: null })).toBe('') + }) + + it('should handle array arguments', () => { + expect(classNames(['foo', 'bar'])).toBe('foo bar') + expect(classNames(['foo'], ['bar'])).toBe('foo bar') + expect(classNames(['foo', null])).toBe('foo') + }) + + it('should handle nested arrays', () => { + expect(classNames(['foo', ['bar', 'baz']])).toBe('foo bar baz') + expect(classNames(['foo', ['bar', ['baz', 'qux']]])).toBe('foo bar baz qux') + }) + + it('should handle mixed argument types', () => { + expect(classNames('foo', { bar: true, baz: false }, ['qux'])).toBe('foo bar qux') + expect(classNames('a', ['b', { c: true, d: false }], 'e')).toBe('a b c e') + }) + + it('should handle complex combinations', () => { + const result = classNames( + 'btn', + { + 'btn-primary': true, + 'btn-large': false, + 'btn-disabled': null, + 'btn-active': 1 + }, + ['btn-block', ['btn-responsive', { 'btn-focus': true }]] + ) + expect(result).toBe('btn btn-primary btn-active btn-block btn-responsive btn-focus') + }) + + it('should handle empty arguments', () => { + expect(classNames()).toBe('') + expect(classNames(null, undefined, false, '')).toBe('') + expect(classNames({})).toBe('') + expect(classNames([])).toBe('') + }) + + it('should filter out empty strings after processing', () => { + expect(classNames({ '': true })).toBe('') + expect(classNames([''])).toBe('') + expect(classNames('foo', '', 'bar')).toBe('foo bar') + }) + }) +}) diff --git a/src/renderer/src/utils/blacklistMatchPattern.test.ts b/src/renderer/src/utils/blacklistMatchPattern.test.ts deleted file mode 100644 index e3086ba5..00000000 --- a/src/renderer/src/utils/blacklistMatchPattern.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -/* - * MIT License - * - * Copyright (c) 2018 iorate - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * - * https://github.com/iorate/ublacklist - */ - -import assert from 'node:assert' -import { test } from 'node:test' - -import { MatchPatternMap } from './blacklistMatchPattern' - -function get(map: MatchPatternMap<number>, url: string) { - return map.get(url).sort() -} - -test('MatchPatternMap', async (t) => { - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns - await t.test('MDN Examples', () => { - const map = new MatchPatternMap<number>() - map.set('<all_urls>', 0) - map.set('*://*/*', 1) - map.set('*://*.mozilla.org/*', 2) - map.set('*://mozilla.org/', 3) - assert.throws(() => map.set('ftp://mozilla.org/', 4)) - map.set('https://*/path', 5) - map.set('https://*/path/', 6) - map.set('https://mozilla.org/*', 7) - map.set('https://mozilla.org/a/b/c/', 8) - map.set('https://mozilla.org/*/b/*/', 9) - assert.throws(() => map.set('file:///blah/*', 10)) - // <all_urls> - assert.deepStrictEqual(get(map, 'http://example.org/'), [0, 1]) - assert.deepStrictEqual(get(map, 'https://a.org/some/path/'), [0, 1]) - assert.deepStrictEqual(get(map, 'ws://sockets.somewhere.org/'), []) - assert.deepStrictEqual(get(map, 'wss://ws.example.com/stuff/'), []) - assert.deepStrictEqual(get(map, 'ftp://files.somewhere.org/'), []) - assert.deepStrictEqual(get(map, 'resource://a/b/c/'), []) - assert.deepStrictEqual(get(map, 'ftps://files.somewhere.org/'), []) - // *://*/* - assert.deepStrictEqual(get(map, 'http://example.org/'), [0, 1]) - assert.deepStrictEqual(get(map, 'https://a.org/some/path/'), [0, 1]) - assert.deepStrictEqual(get(map, 'ws://sockets.somewhere.org/'), []) - assert.deepStrictEqual(get(map, 'wss://ws.example.com/stuff/'), []) - assert.deepStrictEqual(get(map, 'ftp://ftp.example.org/'), []) - assert.deepStrictEqual(get(map, 'file:///a/'), []) - // *://*.mozilla.org/* - assert.deepStrictEqual(get(map, 'http://mozilla.org/'), [0, 1, 2, 3]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/'), [0, 1, 2, 3, 7]) - assert.deepStrictEqual(get(map, 'http://a.mozilla.org/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'http://a.b.mozilla.org/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'https://b.mozilla.org/path/'), [0, 1, 2, 6]) - assert.deepStrictEqual(get(map, 'ws://ws.mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'wss://secure.mozilla.org/something'), []) - assert.deepStrictEqual(get(map, 'ftp://mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'http://mozilla.com/'), [0, 1]) - assert.deepStrictEqual(get(map, 'http://firefox.org/'), [0, 1]) - // *://mozilla.org/ - assert.deepStrictEqual(get(map, 'http://mozilla.org/'), [0, 1, 2, 3]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/'), [0, 1, 2, 3, 7]) - assert.deepStrictEqual(get(map, 'ws://mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'wss://mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'ftp://mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'http://a.mozilla.org/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'http://mozilla.org/a'), [0, 1, 2]) - // ftp://mozilla.org/ - assert.deepStrictEqual(get(map, 'ftp://mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'http://mozilla.org/'), [0, 1, 2, 3]) - assert.deepStrictEqual(get(map, 'ftp://sub.mozilla.org/'), []) - assert.deepStrictEqual(get(map, 'ftp://mozilla.org/path'), []) - // https://*/path - assert.deepStrictEqual(get(map, 'https://mozilla.org/path'), [0, 1, 2, 5, 7]) - assert.deepStrictEqual(get(map, 'https://a.mozilla.org/path'), [0, 1, 2, 5]) - assert.deepStrictEqual(get(map, 'https://something.com/path'), [0, 1, 5]) - assert.deepStrictEqual(get(map, 'http://mozilla.org/path'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path/'), [0, 1, 2, 6, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a'), [0, 1, 2, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/'), [0, 1, 2, 3, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path?foo=1'), [0, 1, 2, 7]) - // https://*/path/ - assert.deepStrictEqual(get(map, 'http://mozilla.org/path/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'https://a.mozilla.org/path/'), [0, 1, 2, 6]) - assert.deepStrictEqual(get(map, 'https://something.com/path/'), [0, 1, 6]) - assert.deepStrictEqual(get(map, 'http://mozilla.org/path/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path'), [0, 1, 2, 5, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a'), [0, 1, 2, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/'), [0, 1, 2, 3, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path?foo=1'), [0, 1, 2, 7]) - // https://mozilla.org/* - assert.deepStrictEqual(get(map, 'https://mozilla.org/'), [0, 1, 2, 3, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path'), [0, 1, 2, 5, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/another'), [0, 1, 2, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path/to/doc'), [0, 1, 2, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/path/to/doc?foo=1'), [0, 1, 2, 7]) - // https://mozilla.org/a/b/c/ - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/'), [0, 1, 2, 7, 8, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/#section1'), [0, 1, 2, 7, 8, 9]) - // https://mozilla.org/*/b/*/ - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/'), [0, 1, 2, 7, 8, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/d/b/f/'), [0, 1, 2, 7, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/d/'), [0, 1, 2, 7, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/d/#section1'), [0, 1, 2, 7, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/d/?foo=/'), [0, 1, 2, 7, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a?foo=21314&bar=/b/&extra=c/'), [0, 1, 2, 7, 9]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/b/*/'), [0, 1, 2, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/'), [0, 1, 2, 7]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/a/b/c/d/?foo=bar'), [0, 1, 2, 7]) - // file:///blah/* - assert.deepStrictEqual(get(map, 'file:///blah/'), []) - assert.deepStrictEqual(get(map, 'file:///blah/bleh'), []) - assert.deepStrictEqual(get(map, 'file:///bleh/'), []) - // Invalid match patterns - assert.throws(() => map.set('resource://path/', 11)) - assert.throws(() => map.set('https://mozilla.org', 12)) - assert.throws(() => map.set('https://mozilla.*.org/', 13)) - assert.throws(() => map.set('https://*zilla.org', 14)) - assert.throws(() => map.set('http*://mozilla.org/', 15)) - assert.throws(() => map.set('https://mozilla.org:80/', 16)) - assert.throws(() => map.set('*//*', 17)) - assert.throws(() => map.set('file://*', 18)) - }) - - await t.test('Serialization', () => { - let map = new MatchPatternMap<number>() - map.set('<all_urls>', 0) - map.set('*://*/*', 1) - map.set('*://*.mozilla.org/*', 2) - map.set('*://mozilla.org/', 3) - map.set('https://*/path', 5) - map.set('https://*/path/', 6) - map.set('https://mozilla.org/*', 7) - map.set('https://mozilla.org/a/b/c/', 8) - map.set('https://mozilla.org/*/b/*/', 9) - const json = map.toJSON() - assert.strictEqual( - JSON.stringify(json), - '[[0],[[],[[1],[5,"https","/path"],[6,"https","/path/"]],{"org":[[],[],{"mozilla":[[[3,"*","/"],[7,"https"],[8,"https","/a/b/c/"],[9,"https","/*/b/*/"]],[[2]]]}]}]]' - ) - map = new MatchPatternMap(json) - assert.deepStrictEqual(get(map, 'http://mozilla.org/'), [0, 1, 2, 3]) - assert.deepStrictEqual(get(map, 'https://mozilla.org/'), [0, 1, 2, 3, 7]) - assert.deepStrictEqual(get(map, 'http://a.mozilla.org/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'http://a.b.mozilla.org/'), [0, 1, 2]) - assert.deepStrictEqual(get(map, 'https://b.mozilla.org/path/'), [0, 1, 2, 6]) - }) -}) diff --git a/src/renderer/src/utils/export.ts b/src/renderer/src/utils/export.ts index dc056372..fc6ce6ba 100644 --- a/src/renderer/src/utils/export.ts +++ b/src/renderer/src/utils/export.ts @@ -5,11 +5,42 @@ import { getMessageTitle } from '@renderer/services/MessagesService' import store from '@renderer/store' import { setExportState } from '@renderer/store/runtime' import { Message, Topic } from '@renderer/types' -import { convertMathFormula, removeSpecialCharactersForFileName } from '@renderer/utils/index' +import { removeSpecialCharactersForFileName } from '@renderer/utils/file' +import { convertMathFormula } from '@renderer/utils/markdown' import { markdownToBlocks } from '@tryfabric/martian' import dayjs from 'dayjs' //TODO: 添加对思考内容的支持 +/** + * 从消息内容中提取标题,限制长度并处理换行和标点符号。用于导出功能。 + * @param str 输入字符串 + * @param length 标题最大长度,默认为 80 + * @returns string 提取的标题 + */ +export function getTitleFromString(str: string, length: number = 80) { + let title = str.trimStart().split('\n')[0] + + if (title.includes('。')) { + title = title.split('。')[0] + } else if (title.includes(',')) { + title = title.split(',')[0] + } else if (title.includes('.')) { + title = title.split('.')[0] + } else if (title.includes(',')) { + title = title.split(',')[0] + } + + if (title.length > length) { + title = title.slice(0, length) + } + + if (!title) { + title = str.slice(0, length) + } + + return title +} + export const messageToMarkdown = (message: Message) => { const { forceDollarMathInMarkdown } = store.getState().settings const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant' diff --git a/src/renderer/src/utils/file.ts b/src/renderer/src/utils/file.ts new file mode 100644 index 00000000..5628963b --- /dev/null +++ b/src/renderer/src/utils/file.ts @@ -0,0 +1,57 @@ +import { KB, MB } from '@shared/config/constant' + +/** + * 从文件路径中提取目录路径。 + * @param filePath 文件路径 + * @returns string 目录路径 + */ +export function getFileDirectory(filePath: string) { + const parts = filePath.split('/') + const directory = parts.slice(0, -1).join('/') + return directory +} + +/** + * 从文件路径中提取文件扩展名。 + * @param filePath 文件路径 + * @returns string 文件扩展名(小写),如果没有则返回 '.' + */ +export function getFileExtension(filePath: string) { + const parts = filePath.split('.') + if (parts.length > 1) { + const extension = parts.slice(-1)[0].toLowerCase() + return '.' + extension + } + return '.' +} + +/** + * 格式化文件大小,根据大小返回以 MB 或 KB 为单位的字符串。 + * @param size 文件大小(字节) + * @returns string 格式化后的文件大小字符串 + */ +export function formatFileSize(size: number) { + if (size >= MB) { + return (size / MB).toFixed(1) + ' MB' + } + + if (size >= KB) { + return (size / KB).toFixed(0) + ' KB' + } + + return (size / KB).toFixed(2) + ' KB' +} + +/** + * 从文件名中移除特殊字符: + * - 替换非法字符为下划线 + * - 替换换行符为空格。 + * @param str 输入字符串 + * @returns string 处理后的文件名字符串 + */ +export function removeSpecialCharactersForFileName(str: string) { + return str + .replace(/[<>:"/\\|?*.]/g, '_') + .replace(/[\r\n]+/g, ' ') + .trim() +} diff --git a/src/renderer/src/utils/formats.ts b/src/renderer/src/utils/formats.ts index e20e44b9..b938d70c 100644 --- a/src/renderer/src/utils/formats.ts +++ b/src/renderer/src/utils/formats.ts @@ -38,11 +38,20 @@ $$ } export function extractTitle(html: string): string | null { + // 处理标准闭合的标题标签 const titleRegex = /<title>(.*?)<\/title>/i const match = html.match(titleRegex) - if (match && match[1]) { - return match[1].trim() + if (match) { + return match[1] ? match[1].trim() : '' + } + + // 处理未闭合的标题标签 + const malformedTitleRegex = /<title>(.*?)($|<(?!\/title))/i + const malformedMatch = html.match(malformedTitleRegex) + + if (malformedMatch) { + return malformedMatch[1] ? malformedMatch[1].trim() : '' } return null @@ -191,20 +200,17 @@ export function withGenerateImage(message: Message) { return message } - const cleanImgContent = message.content - .replace(imagePattern, '') - .replace(/\n\s*\n/g, '\n') - .trim() + // 替换图片语法,保留其他内容 + let cleanContent = message.content.replace(imagePattern, '').trim() + // 检查是否有下载链接 const downloadPattern = new RegExp(`\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`) - const downloadMatches = cleanImgContent.match(downloadPattern) + const downloadMatches = cleanContent.match(downloadPattern) - let cleanContent = cleanImgContent + // 如果有下载链接,只保留图片前的内容 if (downloadMatches) { - cleanContent = cleanImgContent - .replace(downloadPattern, '') - .replace(/\n\s*\n/g, '\n') - .trim() + const contentBeforeImage = message.content.split(imageMatches[0])[0].trim() + cleanContent = contentBeforeImage } message = { @@ -233,5 +239,5 @@ export function addImageFileToContents(messages: Message[]) { images: imageFiles } - return messages.map((message) => (message.role === 'assistant' ? updatedAssistantMessage : message)) + return messages.map((message) => (message.id === lastAssistantMessage.id ? updatedAssistantMessage : message)) } diff --git a/src/renderer/src/utils/image.ts b/src/renderer/src/utils/image.ts new file mode 100644 index 00000000..49a38150 --- /dev/null +++ b/src/renderer/src/utils/image.ts @@ -0,0 +1,170 @@ +import i18n from '@renderer/i18n' +import imageCompression from 'browser-image-compression' +import * as htmlToImage from 'html-to-image' + +/** + * 将文件转换为 Base64 编码的字符串或 ArrayBuffer。 + * @param file 要转换的文件 + * @returns Promise<string | ArrayBuffer | null> 转换后的 Base64 编码数据,如果出错则返回 null + */ +export const convertToBase64 = (file: File): Promise<string | ArrayBuffer | null> => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result) + reader.onerror = reject + reader.readAsDataURL(file) + }) +} + +/** + * 压缩图像文件,限制最大大小和尺寸。 + * @param file 要压缩的图像文件 + * @returns Promise<File> 压缩后的图像文件 + */ +export const compressImage = async (file: File) => { + return await imageCompression(file, { + maxSizeMB: 1, + maxWidthOrHeight: 300, + useWebWorker: false + }) +} + +/** + * 捕获指定 div 元素的图像数据。 + * @param divRef div 元素的引用 + * @returns Promise<string | undefined> 图像数据 URL,如果失败则返回 undefined + */ +export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) { + if (divRef.current) { + try { + const canvas = await htmlToImage.toCanvas(divRef.current) + const imageData = canvas.toDataURL('image/png') + return imageData + } catch (error) { + console.error('Error capturing div:', error) + return Promise.reject() + } + } + return Promise.resolve(undefined) +} + +/** + * 捕获可滚动 div 元素的完整内容图像。 + * @param divRef 可滚动 div 元素的引用 + * @returns Promise<HTMLCanvasElement | undefined> 捕获的画布对象,如果失败则返回 undefined + */ +export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElement | null>) => { + if (divRef.current) { + try { + const div = divRef.current + + // Save original styles + const originalStyle = { + height: div.style.height, + maxHeight: div.style.maxHeight, + overflow: div.style.overflow, + position: div.style.position + } + + const originalScrollTop = div.scrollTop + + // Modify styles to show full content + div.style.height = 'auto' + div.style.maxHeight = 'none' + div.style.overflow = 'visible' + div.style.position = 'static' + + // calculate the size of the div + const totalWidth = div.scrollWidth + const totalHeight = div.scrollHeight + + // check if the size of the div is too large + const MAX_ALLOWED_DIMENSION = 32767 // the maximum allowed pixel size + if (totalHeight > MAX_ALLOWED_DIMENSION || totalWidth > MAX_ALLOWED_DIMENSION) { + // restore the original styles + div.style.height = originalStyle.height + div.style.maxHeight = originalStyle.maxHeight + div.style.overflow = originalStyle.overflow + div.style.position = originalStyle.position + + // restore the original scroll position + setTimeout(() => { + div.scrollTop = originalScrollTop + }, 0) + + window.message.error({ + content: i18n.t('message.error.dimension_too_large'), + key: 'export-error' + }) + return Promise.reject() + } + + const canvas = await new Promise<HTMLCanvasElement>((resolve, reject) => { + htmlToImage + .toCanvas(div, { + backgroundColor: getComputedStyle(div).getPropertyValue('--color-background'), + cacheBust: true, + pixelRatio: window.devicePixelRatio, + skipAutoScale: true, + canvasWidth: div.scrollWidth, + canvasHeight: div.scrollHeight, + style: { + backgroundColor: getComputedStyle(div).backgroundColor, + color: getComputedStyle(div).color + } + }) + .then((canvas) => resolve(canvas)) + .catch((error) => reject(error)) + }) + + // Restore original styles + div.style.height = originalStyle.height + div.style.maxHeight = originalStyle.maxHeight + div.style.overflow = originalStyle.overflow + div.style.position = originalStyle.position + + const imageData = canvas + + // Restore original scroll position + setTimeout(() => { + div.scrollTop = originalScrollTop + }, 0) + + return imageData + } catch (error) { + console.error('Error capturing scrollable div:', error) + throw error + } + } + + return Promise.resolve(undefined) +} + +/** + * 将可滚动 div 元素的图像数据转换为 Data URL 格式。 + * @param divRef 可滚动 div 元素的引用 + * @returns Promise<string | undefined> 图像数据 URL,如果失败则返回 undefined + */ +export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTMLDivElement | null>) => { + return captureScrollableDiv(divRef).then((canvas) => { + if (canvas) { + return canvas.toDataURL('image/png') + } + return Promise.resolve(undefined) + }) +} + +/** + * 将可滚动 div 元素的图像数据转换为 Blob 格式。 + * @param divRef 可滚动 div 元素的引用 + * @param func Blob 回调函数 + * @returns Promise<void> 处理结果 + */ +export const captureScrollableDivAsBlob = async ( + divRef: React.RefObject<HTMLDivElement | null>, + func: BlobCallback +) => { + await captureScrollableDiv(divRef).then((canvas) => { + canvas?.toBlob(func, 'image/png') + }) +} diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index ec4f38b2..4ab44f14 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -1,42 +1,22 @@ -import i18n from '@renderer/i18n' import { Model } from '@renderer/types' -import { KB, MB } from '@shared/config/constant' import { ModalFuncProps } from 'antd/es/modal/interface' -import imageCompression from 'browser-image-compression' -import * as htmlToImage from 'html-to-image' // @ts-ignore next-line` import { v4 as uuidv4 } from 'uuid' -import { classNames } from './style' - +/** + * 异步执行一个函数。 + * @param fn 要执行的函数 + * @returns Promise<void> 执行结果 + */ export const runAsyncFunction = async (fn: () => void) => { await fn() } /** - * 判断字符串是否是 json 字符串 - * @param str 字符串 + * 创建一个延迟的 Promise,在指定秒数后解析。 + * @param seconds 延迟的秒数 + * @returns Promise<any> 在指定秒数后解析的 Promise */ -export function isJSON(str: any): boolean { - if (typeof str !== 'string') { - return false - } - - try { - return typeof JSON.parse(str) === 'object' - } catch (e) { - return false - } -} - -export function parseJSON(str: string) { - try { - return JSON.parse(str) - } catch (e) { - return null - } -} - export const delay = (seconds: number) => { return new Promise((resolve) => { setTimeout(() => { @@ -66,78 +46,6 @@ export const waitAsyncFunction = (fn: () => Promise<any>, interval = 200, stopTi export const uuid = () => uuidv4() -export const convertToBase64 = (file: File): Promise<string | ArrayBuffer | null> => { - return new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => resolve(reader.result) - reader.onerror = reject - reader.readAsDataURL(file) - }) -} - -export const compressImage = async (file: File) => { - return await imageCompression(file, { - maxSizeMB: 1, - maxWidthOrHeight: 300, - useWebWorker: false - }) -} - -// Converts 'gpt-3.5-turbo-16k-0613' to 'GPT-3.5-Turbo' -// Converts 'qwen2:1.5b' to 'QWEN2' -export const getDefaultGroupName = (id: string) => { - if (id.includes('/')) { - return id.split('/')[0] - } - - if (id.includes(':')) { - return id.split(':')[0] - } - - if (id.includes('-')) { - const parts = id.split('-') - return parts[0] + '-' + parts[1] - } - - return id -} - -export function droppableReorder<T>(list: T[], startIndex: number, endIndex: number, len = 1) { - const result = Array.from(list) - const removed = result.splice(startIndex, len) - result.splice(endIndex, 0, ...removed) - return result -} - -export function firstLetter(str: string): string { - const match = str?.match(/\p{L}\p{M}*|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u) - return match ? match[0] : '' -} - -export function removeLeadingEmoji(str: string): string { - const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u - return str.replace(emojiRegex, '').trim() -} - -export function getLeadingEmoji(str: string): string { - const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u - const match = str.match(emojiRegex) - return match ? match[0] : '' -} - -export function isEmoji(str: string) { - if (str.startsWith('data:')) { - return false - } - - if (str.startsWith('http')) { - return false - } - - const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u - return str.match(emojiRegex) -} - export function isFreeModel(model: Model) { return (model.id + model.name).toLocaleLowerCase().includes('free') } @@ -152,6 +60,11 @@ export async function isDev() { return !isProd } +/** + * 从错误对象中提取错误信息。 + * @param error 错误对象或字符串 + * @returns string 提取的错误信息,如果没有则返回空字符串 + */ export function getErrorMessage(error: any) { if (!error) { return '' @@ -181,49 +94,6 @@ export function removeSpecialCharacters(str: string) { return str.replace(/[\n"]/g, '').replace(/[\p{M}\p{P}]/gu, '') } -export function removeSpecialCharactersForTopicName(str: string) { - return str.replace(/[\r\n]+/g, ' ').trim() -} - -export function removeSpecialCharactersForFileName(str: string) { - return str - .replace(/[<>:"/\\|?*.]/g, '_') - .replace(/[\r\n]+/g, ' ') - .trim() -} - -export function generateColorFromChar(char: string) { - // 使用字符的Unicode值作为随机种子 - const seed = char.charCodeAt(0) - - // 使用简单的线性同余生成器创建伪随机数 - const a = 1664525 - const c = 1013904223 - const m = Math.pow(2, 32) - - // 生成三个伪随机数作为RGB值 - let r = (a * seed + c) % m - let g = (a * r + c) % m - let b = (a * g + c) % m - - // 将伪随机数转换为0-255范围内的整数 - r = Math.floor((r / m) * 256) - g = Math.floor((g / m) * 256) - b = Math.floor((b / m) * 256) - - // 返回十六进制颜色字符串 - return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` -} - -export function getFirstCharacter(str) { - if (str.length === 0) return '' - - // 使用 for...of 循环来获取第一个字符 - for (const char of str) { - return char - } -} - /** * is valid proxy url * @param url proxy url @@ -233,6 +103,11 @@ export const isValidProxyUrl = (url: string) => { return url.includes('://') } +/** + * 动态加载 JavaScript 脚本。 + * @param url 脚本的 URL 地址 + * @returns Promise<void> 脚本加载成功或失败的 Promise + */ export function loadScript(url: string) { return new Promise((resolve, reject) => { const script = document.createElement('script') @@ -246,171 +121,11 @@ export function loadScript(url: string) { }) } -export function convertMathFormula(input) { - if (!input) return input - - let result = input - result = result.replaceAll('\\[', '$$$$').replaceAll('\\]', '$$$$') - result = result.replaceAll('\\(', '$$').replaceAll('\\)', '$$') - return result -} - -export function getBriefInfo(text: string, maxLength: number = 50): string { - // 去除空行 - const noEmptyLinesText = text.replace(/\n\s*\n/g, '\n') - - // 检查文本是否超过最大长度 - if (noEmptyLinesText.length <= maxLength) { - return noEmptyLinesText - } - - // 找到最近的单词边界 - let truncatedText = noEmptyLinesText.slice(0, maxLength) - const lastSpaceIndex = truncatedText.lastIndexOf(' ') - - if (lastSpaceIndex !== -1) { - truncatedText = truncatedText.slice(0, lastSpaceIndex) - } - - // 截取前面的内容,并在末尾添加 "..." - return truncatedText + '...' -} - -export function removeTrailingDoubleSpaces(markdown: string): string { - // 使用正则表达式匹配末尾的两个空格,并替换为空字符串 - return markdown.replace(/ {2}$/gm, '') -} - -export function getFileDirectory(filePath: string) { - const parts = filePath.split('/') - const directory = parts.slice(0, -1).join('/') - return directory -} - -export function getFileExtension(filePath: string) { - const parts = filePath.split('.') - const extension = parts.slice(-1)[0].toLowerCase() - return '.' + extension -} - -export async function captureDiv(divRef: React.RefObject<HTMLDivElement>) { - if (divRef.current) { - try { - const canvas = await htmlToImage.toCanvas(divRef.current) - const imageData = canvas.toDataURL('image/png') - return imageData - } catch (error) { - console.error('Error capturing div:', error) - return Promise.reject() - } - } - return Promise.resolve(undefined) -} - -export const captureScrollableDiv = async (divRef: React.RefObject<HTMLDivElement | null>) => { - if (divRef.current) { - try { - const div = divRef.current - - // Save original styles - const originalStyle = { - height: div.style.height, - maxHeight: div.style.maxHeight, - overflow: div.style.overflow, - position: div.style.position - } - - const originalScrollTop = div.scrollTop - - // Modify styles to show full content - div.style.height = 'auto' - div.style.maxHeight = 'none' - div.style.overflow = 'visible' - div.style.position = 'static' - - // calculate the size of the div - const totalWidth = div.scrollWidth - const totalHeight = div.scrollHeight - - // check if the size of the div is too large - const MAX_ALLOWED_DIMENSION = 32767 // the maximum allowed pixel size - if (totalHeight > MAX_ALLOWED_DIMENSION || totalWidth > MAX_ALLOWED_DIMENSION) { - // restore the original styles - div.style.height = originalStyle.height - div.style.maxHeight = originalStyle.maxHeight - div.style.overflow = originalStyle.overflow - div.style.position = originalStyle.position - - // restore the original scroll position - setTimeout(() => { - div.scrollTop = originalScrollTop - }, 0) - - window.message.error({ - content: i18n.t('message.error.dimension_too_large'), - key: 'export-error' - }) - return Promise.reject() - } - - const canvas = await new Promise<HTMLCanvasElement>((resolve, reject) => { - htmlToImage - .toCanvas(div, { - backgroundColor: getComputedStyle(div).getPropertyValue('--color-background'), - cacheBust: true, - pixelRatio: window.devicePixelRatio, - skipAutoScale: true, - canvasWidth: div.scrollWidth, - canvasHeight: div.scrollHeight, - style: { - backgroundColor: getComputedStyle(div).backgroundColor, - color: getComputedStyle(div).color - } - }) - .then((canvas) => resolve(canvas)) - .catch((error) => reject(error)) - }) - - // Restore original styles - div.style.height = originalStyle.height - div.style.maxHeight = originalStyle.maxHeight - div.style.overflow = originalStyle.overflow - div.style.position = originalStyle.position - - const imageData = canvas - - // Restore original scroll position - setTimeout(() => { - div.scrollTop = originalScrollTop - }, 0) - - return imageData - } catch (error) { - console.error('Error capturing scrollable div:', error) - } - } - - return Promise.resolve(undefined) -} - -export const captureScrollableDivAsDataURL = async (divRef: React.RefObject<HTMLDivElement | null>) => { - return captureScrollableDiv(divRef).then((canvas) => { - if (canvas) { - return canvas.toDataURL('image/png') - } - return Promise.resolve(undefined) - }) -} - -export const captureScrollableDivAsBlob = async ( - divRef: React.RefObject<HTMLDivElement | null>, - func: BlobCallback -) => { - await captureScrollableDiv(divRef).then((canvas) => { - canvas?.toBlob(func, 'image/png') - }) -} - +/** + * 检查 URL 是否包含路径部分。 + * @param url 输入 URL 字符串 + * @returns boolean 如果 URL 包含路径则返回 true,否则返回 false + */ export function hasPath(url: string): boolean { try { const parsedUrl = new URL(url) @@ -421,26 +136,12 @@ export function hasPath(url: string): boolean { } } -export function formatFileSize(size: number) { - if (size > MB) { - return (size / MB).toFixed(1) + ' MB' - } - - if (size > KB) { - return (size / KB).toFixed(0) + ' KB' - } - - return (size / KB).toFixed(2) + ' KB' -} - -export function sortByEnglishFirst(a: string, b: string) { - const isAEnglish = /^[a-zA-Z]/.test(a) - const isBEnglish = /^[a-zA-Z]/.test(b) - if (isAEnglish && !isBEnglish) return -1 - if (!isAEnglish && isBEnglish) return 1 - return a.localeCompare(b) -} - +/** + * 比较两个版本号字符串。 + * @param v1 第一个版本号 + * @param v2 第二个版本号 + * @returns number 比较结果,1 表示 v1 大于 v2,-1 表示 v1 小于 v2,0 表示相等 + */ export const compareVersions = (v1: string, v2: string): number => { const v1Parts = v1.split('.').map(Number) const v2Parts = v2.split('.').map(Number) @@ -458,6 +159,11 @@ export function isMiniWindow() { return window.location.hash === '#/mini' } +/** + * 显示确认模态框。 + * @param params 模态框参数 + * @returns Promise<boolean> 用户确认返回 true,取消返回 false + */ export function modalConfirm(params: ModalFuncProps) { return new Promise((resolve) => { window.modal.confirm({ @@ -469,30 +175,12 @@ export function modalConfirm(params: ModalFuncProps) { }) } -export function getTitleFromString(str: string, length: number = 80) { - let title = str.split('\n')[0] - - if (title.includes('。')) { - title = title.split('。')[0] - } else if (title.includes(',')) { - title = title.split(',')[0] - } else if (title.includes('.')) { - title = title.split('.')[0] - } else if (title.includes(',')) { - title = title.split(',')[0] - } - - if (title.length > length) { - title = title.slice(0, length) - } - - if (!title) { - title = str.slice(0, length) - } - - return title -} - +/** + * 检查对象是否包含特定键。 + * @param obj 输入对象 + * @param key 要检查的键 + * @returns boolean 包含该键则返回 true,否则返回 false + */ export function hasObjectKey(obj: any, key: string) { if (typeof obj !== 'object' || obj === null) { return false @@ -524,4 +212,9 @@ export function getMcpConfigSampleFromReadme(readme: string) { return null } -export { classNames } +export * from './file' +export * from './image' +export * from './json' +export * from './naming' +export * from './sort' +export * from './style' diff --git a/src/renderer/src/utils/json.ts b/src/renderer/src/utils/json.ts new file mode 100644 index 00000000..11babebb --- /dev/null +++ b/src/renderer/src/utils/json.ts @@ -0,0 +1,28 @@ +/** + * 判断字符串是否是 json 字符串 + * @param str 字符串 + */ +export function isJSON(str: any): boolean { + if (typeof str !== 'string') { + return false + } + + try { + return typeof JSON.parse(str) === 'object' + } catch (e) { + return false + } +} + +/** + * 尝试解析 JSON 字符串,如果解析失败则返回 null。 + * @param str 要解析的字符串 + * @returns 解析后的对象,如果解析失败则返回 null + */ +export function parseJSON(str: string) { + try { + return JSON.parse(str) + } catch (e) { + return null + } +} diff --git a/src/renderer/src/utils/linkConverter.ts b/src/renderer/src/utils/linkConverter.ts index 50b3d5d0..9cdf1320 100644 --- a/src/renderer/src/utils/linkConverter.ts +++ b/src/renderer/src/utils/linkConverter.ts @@ -384,6 +384,6 @@ function isValidUrl(url: string): boolean { * @returns 清理后的文本 */ export function cleanLinkCommas(text: string): string { - // 匹配两个 Markdown 链接之间的逗号(可能包含空格) - return text.replace(/\]\([^)]+\)\s*,\s*\[/g, ']()[') + // 匹配两个 Markdown 链接之间的英文逗号(可能包含空格) + return text.replace(/\]\(([^)]+)\)\s*,\s*\[/g, ']($1)[') } diff --git a/src/renderer/src/utils/markdown.ts b/src/renderer/src/utils/markdown.ts index f1c72104..b90f9fa9 100644 --- a/src/renderer/src/utils/markdown.ts +++ b/src/renderer/src/utils/markdown.ts @@ -1,23 +1,3 @@ -// 更彻底的查找方法,递归搜索所有子元素 -export const findCitationInChildren = (children) => { - if (!children) return null - - // 直接搜索子元素 - for (const child of Array.isArray(children) ? children : [children]) { - if (typeof child === 'object' && child?.props?.['data-citation']) { - return child.props['data-citation'] - } - - // 递归查找更深层次 - if (typeof child === 'object' && child?.props?.children) { - const found = findCitationInChildren(child.props.children) - if (found) return found - } - } - - return null -} - export const MARKDOWN_ALLOWED_TAGS = [ 'style', 'p', @@ -81,3 +61,49 @@ export const sanitizeSchema = { a: ['href', 'target', 'rel'] } } + +// 更彻底的查找方法,递归搜索所有子元素 +export const findCitationInChildren = (children) => { + if (!children) return null + + // 直接搜索子元素 + for (const child of Array.isArray(children) ? children : [children]) { + if (typeof child === 'object' && child?.props?.['data-citation']) { + return child.props['data-citation'] + } + + // 递归查找更深层次 + if (typeof child === 'object' && child?.props?.children) { + const found = findCitationInChildren(child.props.children) + if (found) return found + } + } + + return null +} + +/** + * 转换数学公式格式: + * - 将 LaTeX 格式的 '\\[' 和 '\\]' 转换为 '$$$$'。 + * - 将 LaTeX 格式的 '\\(' 和 '\\)' 转换为 '$$'。 + * @param input 输入字符串 + * @returns string 转换后的字符串 + */ +export function convertMathFormula(input) { + if (!input) return input + + let result = input + result = result.replaceAll('\\[', '$$$$').replaceAll('\\]', '$$$$') + result = result.replaceAll('\\(', '$$').replaceAll('\\)', '$$') + return result +} + +/** + * 移除 Markdown 文本中每行末尾的两个空格。 + * @param markdown 输入的 Markdown 文本 + * @returns string 处理后的文本 + */ +export function removeTrailingDoubleSpaces(markdown: string): string { + // 使用正则表达式匹配末尾的两个空格,并替换为空字符串 + return markdown.replace(/ {2}$/gm, '') +} diff --git a/src/renderer/src/utils/naming.ts b/src/renderer/src/utils/naming.ts new file mode 100644 index 00000000..de0011ac --- /dev/null +++ b/src/renderer/src/utils/naming.ts @@ -0,0 +1,151 @@ +/** + * 从模型 ID 中提取默认组名。 + * 例如: + * - 'gpt-3.5-turbo-16k-0613' 转换为 'GPT-3.5-Turbo' + * - 'qwen2:1.5b' 转换为 'QWEN2'。 + * @param id 模型 ID 字符串 + * @returns string 提取的组名 + */ +export const getDefaultGroupName = (id: string) => { + if (id.includes('/')) { + return id.split('/')[0] + } + + if (id.includes(':')) { + return id.split(':')[0] + } + + if (id.includes('-')) { + const parts = id.split('-') + return parts[0] + '-' + parts[1] + } + + return id +} + +/** + * 用于获取 avatar 名字的辅助函数,会取出字符串的第一个字符,支持表情符号。 + * @param str 输入字符串 + * @returns string 第一个字符,或者返回空字符串 + */ +export function firstLetter(str: string): string { + const match = str?.match(/\p{L}\p{M}*|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u) + return match ? match[0] : '' +} + +/** + * 移除字符串开头的表情符号。 + * @param str 输入字符串 + * @returns string 移除开头表情符号后的字符串 + */ +export function removeLeadingEmoji(str: string): string { + const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u + return str.replace(emojiRegex, '').trim() +} + +/** + * 提取字符串开头的表情符号。 + * @param str 输入字符串 + * @returns string 开头的表情符号,如果没有则返回空字符串 + */ +export function getLeadingEmoji(str: string): string { + const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+/u + const match = str.match(emojiRegex) + return match ? match[0] : '' +} + +/** + * 检查字符串是否为纯表情符号。 + * @param str 输入字符串 + * @returns boolean 如果字符串是纯表情符号则返回 true,否则返回 false + */ +export function isEmoji(str: string): boolean { + if (str.startsWith('data:')) { + return false + } + if (str.startsWith('http')) { + return false + } + const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+$/u + const match = str.match(emojiRegex) + return !!match +} + +/** + * 从话题名称中移除特殊字符: + * - 替换换行符为空格。 + * @param str 输入字符串 + * @returns string 处理后的字符串 + */ +export function removeSpecialCharactersForTopicName(str: string) { + return str.replace(/[\r\n]+/g, ' ').trim() +} + +/** + * 根据字符生成颜色代码,用于 avatar。 + * @param char 输入字符 + * @returns string 十六进制颜色字符串 + */ +export function generateColorFromChar(char: string) { + // 使用字符的Unicode值作为随机种子 + const seed = char.charCodeAt(0) + + // 使用简单的线性同余生成器创建伪随机数 + const a = 1664525 + const c = 1013904223 + const m = Math.pow(2, 32) + + // 生成三个伪随机数作为RGB值 + let r = (a * seed + c) % m + let g = (a * r + c) % m + let b = (a * g + c) % m + + // 将伪随机数转换为0-255范围内的整数 + r = Math.floor((r / m) * 256) + g = Math.floor((g / m) * 256) + b = Math.floor((b / m) * 256) + + // 返回十六进制颜色字符串 + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` +} + +/** + * 获取字符串的第一个字符。 + * @param str 输入字符串 + * @returns string 第一个字符,或者空字符串 + */ +export function getFirstCharacter(str) { + if (str.length === 0) return '' + + // 使用 for...of 循环来获取第一个字符 + for (const char of str) { + return char + } +} + +/** + * 用于简化文本。按照给定长度限制截断文本,考虑语义边界。 + * @param text 输入文本 + * @param maxLength 最大长度,默认为 50 + * @returns string 处理后的简短文本 + */ +export function getBriefInfo(text: string, maxLength: number = 50): string { + // 去除空行 + const noEmptyLinesText = text.replace(/\n\s*\n/g, '\n') + + // 检查文本是否超过最大长度 + if (noEmptyLinesText.length <= maxLength) { + return noEmptyLinesText + } + + // 找到最近的单词边界 + let truncatedText = noEmptyLinesText.slice(0, maxLength) + const lastSpaceIndex = truncatedText.lastIndexOf(' ') + + if (lastSpaceIndex !== -1) { + truncatedText = truncatedText.slice(0, lastSpaceIndex) + } + + // 截取前面的内容,并在末尾添加 "..." + return truncatedText + '...' +} diff --git a/src/renderer/src/utils/sort.ts b/src/renderer/src/utils/sort.ts new file mode 100644 index 00000000..d26a86c1 --- /dev/null +++ b/src/renderer/src/utils/sort.ts @@ -0,0 +1,34 @@ +/** + * 用于 dnd 列表的元素重新排序方法。支持多元素"拖动"排序。 + * @template T 列表元素的类型 + * @param list 要重新排序的列表 + * @param sourceIndex 起始元素索引 + * @param destIndex 目标元素索引 + * @param len 要移动的元素数量,默认为 1 + * @returns T[] 重新排序后的列表 + */ +export function droppableReorder<T>(list: T[], sourceIndex: number, destIndex: number, len = 1) { + const result = Array.from(list) + const removed = result.splice(sourceIndex, len) + + if (sourceIndex < destIndex) { + result.splice(destIndex - len + 1, 0, ...removed) + } else { + result.splice(destIndex, 0, ...removed) + } + return result +} + +/** + * 首字母为英文的字符串排在前面。 + * @param a 字符串 + * @param b 字符串 + * @returns 排序后的字符串 + */ +export function sortByEnglishFirst(a: string, b: string) { + const isAEnglish = /^[a-zA-Z]/.test(a) + const isBEnglish = /^[a-zA-Z]/.test(b) + if (isAEnglish && !isBEnglish) return -1 + if (!isAEnglish && isBEnglish) return 1 + return a.localeCompare(b) +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..abaeb823 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,54 @@ +import { defineConfig } from 'vitest/config' + +import electronViteConfig from './electron.vite.config' + +const rendererConfig = electronViteConfig.renderer + +export default defineConfig({ + // 复用 renderer 插件和路径别名 + plugins: rendererConfig.plugins, + resolve: { + alias: rendererConfig.resolve.alias + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: [], + include: [ + // 只测试渲染进程 + 'src/renderer/**/*.{test,spec}.{ts,tsx}', + 'src/renderer/**/__tests__/**/*.{ts,tsx}' + ], + exclude: ['**/node_modules/**', '**/dist/**', '**/out/**', '**/build/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + '**/node_modules/**', + '**/dist/**', + '**/out/**', + '**/build/**', + '**/coverage/**', + '**/.yarn/**', + '**/.cursor/**', + '**/.vscode/**', + '**/.github/**', + '**/.husky/**', + '**/*.d.ts', + '**/types/**', + '**/__tests__/**', + '**/*.{test,spec}.{ts,tsx}', + '**/*.config.{js,ts}', + '**/electron.vite.config.ts', + '**/vitest.config.ts' + ] + }, + testTimeout: 20000, + pool: 'threads', + poolOptions: { + threads: { + singleThread: false + } + } + } +}) diff --git a/yarn.lock b/yarn.lock index 8a758cc3..4f56807a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -67,7 +67,7 @@ __metadata: languageName: node linkType: hard -"@ampproject/remapping@npm:^2.2.0": +"@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0": version: 2.3.0 resolution: "@ampproject/remapping@npm:2.3.0" dependencies: @@ -296,7 +296,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.24.7, @babel/core@npm:^7.26.0": +"@babel/core@npm:^7.24.7, @babel/core@npm:^7.26.10": version: 7.26.10 resolution: "@babel/core@npm:7.26.10" dependencies: @@ -415,7 +415,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.27.0": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.25.4, @babel/parser@npm:^7.26.10, @babel/parser@npm:^7.27.0": version: 7.27.0 resolution: "@babel/parser@npm:7.27.0" dependencies: @@ -505,7 +505,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.25.4, @babel/types@npm:^7.25.9, @babel/types@npm:^7.26.10, @babel/types@npm:^7.27.0": version: 7.27.0 resolution: "@babel/types@npm:7.27.0" dependencies: @@ -515,6 +515,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.2": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@buttercup/fetch@npm:^0.2.1": version: 0.2.1 resolution: "@buttercup/fetch@npm:0.2.1" @@ -1677,6 +1684,13 @@ __metadata: languageName: node linkType: hard +"@istanbuljs/schema@npm:^0.1.2": + version: 0.1.3 + resolution: "@istanbuljs/schema@npm:0.1.3" + checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a + languageName: node + linkType: hard + "@jimp/bmp@npm:^0.16.13": version: 0.16.13 resolution: "@jimp/bmp@npm:0.16.13" @@ -2133,7 +2147,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": +"@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" dependencies: @@ -3004,6 +3018,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.29 + resolution: "@polka/url@npm:1.0.0-next.29" + checksum: 10c0/0d58e081844095cb029d3c19a659bfefd09d5d51a2f791bc61eba7ea826f13d6ee204a8a448c2f5a855c17df07b37517373ff916dd05801063c0568ae9937684 + languageName: node + linkType: hard + "@rc-component/async-validator@npm:^5.0.3": version: 5.0.4 resolution: "@rc-component/async-validator@npm:5.0.4" @@ -4067,18 +4088,142 @@ __metadata: languageName: node linkType: hard -"@vitejs/plugin-react@npm:4.3.4": - version: 4.3.4 - resolution: "@vitejs/plugin-react@npm:4.3.4" +"@vitejs/plugin-react@npm:^4.4.1": + version: 4.4.1 + resolution: "@vitejs/plugin-react@npm:4.4.1" dependencies: - "@babel/core": "npm:^7.26.0" + "@babel/core": "npm:^7.26.10" "@babel/plugin-transform-react-jsx-self": "npm:^7.25.9" "@babel/plugin-transform-react-jsx-source": "npm:^7.25.9" "@types/babel__core": "npm:^7.20.5" - react-refresh: "npm:^0.14.2" + react-refresh: "npm:^0.17.0" peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 - checksum: 10c0/38a47a1dbafae0b97142943d83ee3674cb3331153a60b1a3fd29d230c12c9dfe63b7c345b231a3450168ed8a9375a9a1a253c3d85e9efdc19478c0d56b98496c + checksum: 10c0/0eda45f2026cdfff4b172b1b2148824e5ac41ce65f1f1ce108f3ce4de2f0024caf79c811c1305a782168a269b0b1bc58d4cf8eaf164e4ef19954f05428ba7077 + languageName: node + linkType: hard + +"@vitest/coverage-v8@npm:^3.1.1": + version: 3.1.1 + resolution: "@vitest/coverage-v8@npm:3.1.1" + dependencies: + "@ampproject/remapping": "npm:^2.3.0" + "@bcoe/v8-coverage": "npm:^1.0.2" + debug: "npm:^4.4.0" + istanbul-lib-coverage: "npm:^3.2.2" + istanbul-lib-report: "npm:^3.0.1" + istanbul-lib-source-maps: "npm:^5.0.6" + istanbul-reports: "npm:^3.1.7" + magic-string: "npm:^0.30.17" + magicast: "npm:^0.3.5" + std-env: "npm:^3.8.1" + test-exclude: "npm:^7.0.1" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + "@vitest/browser": 3.1.1 + vitest: 3.1.1 + peerDependenciesMeta: + "@vitest/browser": + optional: true + checksum: 10c0/0f852d8a438d27605955f2a1177e017f48b0dcdc7069318b2b1e031e3561d02a54e4d9a108afacbc8365c8b42f4bcb13282ae7cfaf380bce27741991321e83d9 + languageName: node + linkType: hard + +"@vitest/expect@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/expect@npm:3.1.1" + dependencies: + "@vitest/spy": "npm:3.1.1" + "@vitest/utils": "npm:3.1.1" + chai: "npm:^5.2.0" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/ef4528d0ebb89eb3cc044cf597d051c35df8471bb6ba4029e9b3412aa69d0d85a0ce4eb49531fc78fe1ebd97e6428260463068cc96a8d8c1a80150dedfd1ab3a + languageName: node + linkType: hard + +"@vitest/mocker@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/mocker@npm:3.1.1" + dependencies: + "@vitest/spy": "npm:3.1.1" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.17" + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/9264558809e2d7c77ae9ceefad521dc5f886a567aaf0bdd021b73089b8906ffd92c893f3998d16814f38fc653c7413836f508712355c87749a0e86c7d435eec1 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:3.1.1, @vitest/pretty-format@npm:^3.1.1": + version: 3.1.1 + resolution: "@vitest/pretty-format@npm:3.1.1" + dependencies: + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/540cd46d317fc80298c93b185f3fb48dfe90eaaa3942fd700fde6e88d658772c01b56ad5b9b36e4ac368a02e0fc8e0dc72bbdd6dd07a5d75e89ef99c8df5ba6e + languageName: node + linkType: hard + +"@vitest/runner@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/runner@npm:3.1.1" + dependencies: + "@vitest/utils": "npm:3.1.1" + pathe: "npm:^2.0.3" + checksum: 10c0/35a541069c3c94a2dd02fca2d70cc8d5e66ba2e891cfb80da354174f510aeb96774ffb34fff39cecde9d5c969be4dd20e240a900beb9b225b7512a615ecc5503 + languageName: node + linkType: hard + +"@vitest/snapshot@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/snapshot@npm:3.1.1" + dependencies: + "@vitest/pretty-format": "npm:3.1.1" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + checksum: 10c0/43e5fc5db580f20903eb1493d07f08752df8864f7b9b7293a202b2ffe93d8c196a5614d66dda096c6bacc16e12f1836f33ba41898812af6d32676d1eb501536a + languageName: node + linkType: hard + +"@vitest/spy@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/spy@npm:3.1.1" + dependencies: + tinyspy: "npm:^3.0.2" + checksum: 10c0/896659d4b42776cfa2057a1da2c33adbd3f2ebd28005ca606d1616d08d2e726dc1460fb37f1ea7f734756b5bccf926c7165f410e63f0a3b8d992eb5489528b08 + languageName: node + linkType: hard + +"@vitest/ui@npm:^3.1.1": + version: 3.1.1 + resolution: "@vitest/ui@npm:3.1.1" + dependencies: + "@vitest/utils": "npm:3.1.1" + fflate: "npm:^0.8.2" + flatted: "npm:^3.3.3" + pathe: "npm:^2.0.3" + sirv: "npm:^3.0.1" + tinyglobby: "npm:^0.2.12" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + vitest: 3.1.1 + checksum: 10c0/03bd014a4afa2c4cd6007d8000d881c653414f30d275fe35067b3d50c8a07b9f53cb2a294a8d36adaece7e4671030f90bd51aedb412d64479b981e051e7996ba + languageName: node + linkType: hard + +"@vitest/utils@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/utils@npm:3.1.1" + dependencies: + "@vitest/pretty-format": "npm:3.1.1" + loupe: "npm:^3.1.3" + tinyrainbow: "npm:^2.0.0" + checksum: 10c0/a9cfe0c0f095b58644ce3ba08309de5be8564c10dad9e62035bd378e60b2834e6a256e6e4ded7dcf027fdc2371301f7965040ad3e6323b747d5b3abbb7ceb0d6 languageName: node linkType: hard @@ -4195,7 +4340,9 @@ __metadata: "@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/tinycolor2": "npm:^1" "@types/ws": "npm:^8" - "@vitejs/plugin-react": "npm:4.3.4" + "@vitejs/plugin-react": "npm:^4.4.1" + "@vitest/coverage-v8": "npm:^3.1.1" + "@vitest/ui": "npm:^3.1.1" "@xyflow/react": "npm:^12.4.4" adm-zip: "npm:^0.5.16" analytics: "npm:^0.8.16" @@ -4284,6 +4431,7 @@ __metadata: undici: "npm:^7.4.0" uuid: "npm:^10.0.0" vite: "npm:6.2.6" + vitest: "npm:^3.1.1" webdav: "npm:^5.8.0" ws: "npm:^8.18.1" zipread: "npm:^1.3.3" @@ -4768,6 +4916,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "ast-types@npm:^0.13.4": version: 0.13.4 resolution: "ast-types@npm:0.13.4" @@ -5381,6 +5536,19 @@ __metadata: languageName: node linkType: hard +"chai@npm:^5.2.0": + version: 5.2.0 + resolution: "chai@npm:5.2.0" + dependencies: + assertion-error: "npm:^2.0.1" + check-error: "npm:^2.1.1" + deep-eql: "npm:^5.0.1" + loupe: "npm:^3.1.0" + pathval: "npm:^2.0.0" + checksum: 10c0/dfd1cb719c7cebb051b727672d382a35338af1470065cb12adb01f4ee451bbf528e0e0f9ab2016af5fc1eea4df6e7f4504dc8443f8f00bd8fb87ad32dc516f7d + languageName: node + linkType: hard + "chalk@npm:2.4.2": version: 2.4.2 resolution: "chalk@npm:2.4.2" @@ -5465,6 +5633,13 @@ __metadata: languageName: node linkType: hard +"check-error@npm:^2.1.1": + version: 2.1.1 + resolution: "check-error@npm:2.1.1" + checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e + languageName: node + linkType: hard + "chokidar@npm:^4.0.0": version: 4.0.3 resolution: "chokidar@npm:4.0.3" @@ -6263,6 +6438,13 @@ __metadata: languageName: node linkType: hard +"deep-eql@npm:^5.0.1": + version: 5.0.2 + resolution: "deep-eql@npm:5.0.2" + checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 + languageName: node + linkType: hard + "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -7002,6 +7184,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^1.6.0": + version: 1.6.0 + resolution: "es-module-lexer@npm:1.6.0" + checksum: 10c0/667309454411c0b95c476025929881e71400d74a746ffa1ff4cb450bd87f8e33e8eef7854d68e401895039ac0bac64e7809acbebb6253e055dd49ea9e3ea9212 + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -7634,6 +7823,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "esutils@npm:^2.0.2": version: 2.0.3 resolution: "esutils@npm:2.0.3" @@ -7730,6 +7928,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.2.0": + version: 1.2.1 + resolution: "expect-type@npm:1.2.1" + checksum: 10c0/b775c9adab3c190dd0d398c722531726cdd6022849b4adba19dceab58dda7e000a7c6c872408cd73d665baa20d381eca36af4f7b393a4ba60dd10232d1fb8898 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -7928,6 +8133,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.4.3": + version: 6.4.4 + resolution: "fdir@npm:6.4.4" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/6ccc33be16945ee7bc841e1b4178c0b4cf18d3804894cb482aa514651c962a162f96da7ffc6ebfaf0df311689fb70091b04dd6caffe28d56b9ebdc0e7ccadfdd + languageName: node + linkType: hard + "fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4": version: 3.2.0 resolution: "fetch-blob@npm:3.2.0" @@ -7955,6 +8172,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + "file-entry-cache@npm:^8.0.0": version: 8.0.0 resolution: "file-entry-cache@npm:8.0.0" @@ -8112,7 +8336,7 @@ __metadata: languageName: node linkType: hard -"flatted@npm:^3.2.9": +"flatted@npm:^3.2.9, flatted@npm:^3.3.3": version: 3.3.3 resolution: "flatted@npm:3.3.3" checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 @@ -8557,7 +8781,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.3.12, glob@npm:^10.3.7": +"glob@npm:^10.3.12, glob@npm:^10.3.7, glob@npm:^10.4.1": version: 10.4.5 resolution: "glob@npm:10.4.5" dependencies: @@ -9132,6 +9356,13 @@ __metadata: languageName: node linkType: hard +"html-escaper@npm:^2.0.0": + version: 2.0.2 + resolution: "html-escaper@npm:2.0.2" + checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 + languageName: node + linkType: hard + "html-parse-stringify@npm:^3.0.1": version: 3.0.1 resolution: "html-parse-stringify@npm:3.0.1" @@ -9854,6 +10085,45 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": + version: 3.2.2 + resolution: "istanbul-lib-coverage@npm:3.2.2" + checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b + languageName: node + linkType: hard + +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": + version: 3.0.1 + resolution: "istanbul-lib-report@npm:3.0.1" + dependencies: + istanbul-lib-coverage: "npm:^3.0.0" + make-dir: "npm:^4.0.0" + supports-color: "npm:^7.1.0" + checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 + languageName: node + linkType: hard + +"istanbul-lib-source-maps@npm:^5.0.6": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10c0/ffe75d70b303a3621ee4671554f306e0831b16f39ab7f4ab52e54d356a5d33e534d97563e318f1333a6aae1d42f91ec49c76b6cd3f3fb378addcb5c81da0255f + languageName: node + linkType: hard + +"istanbul-reports@npm:^3.1.7": + version: 3.1.7 + resolution: "istanbul-reports@npm:3.1.7" + dependencies: + html-escaper: "npm:^2.0.0" + istanbul-lib-report: "npm:^3.0.0" + checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 + languageName: node + linkType: hard + "jackspeak@npm:^3.1.2": version: 3.4.3 resolution: "jackspeak@npm:3.4.3" @@ -10612,6 +10882,13 @@ __metadata: languageName: node linkType: hard +"loupe@npm:^3.1.0, loupe@npm:^3.1.3": + version: 3.1.3 + resolution: "loupe@npm:3.1.3" + checksum: 10c0/f5dab4144254677de83a35285be1b8aba58b3861439ce4ba65875d0d5f3445a4a496daef63100ccf02b2dbc25bf58c6db84c9cb0b96d6435331e9d0a33b48541 + languageName: node + linkType: hard + "lowercase-keys@npm:^2.0.0": version: 2.0.0 resolution: "lowercase-keys@npm:2.0.0" @@ -10674,7 +10951,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.10": +"magic-string@npm:^0.30.10, magic-string@npm:^0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17" dependencies: @@ -10683,6 +10960,17 @@ __metadata: languageName: node linkType: hard +"magicast@npm:^0.3.5": + version: 0.3.5 + resolution: "magicast@npm:0.3.5" + dependencies: + "@babel/parser": "npm:^7.25.4" + "@babel/types": "npm:^7.25.4" + source-map-js: "npm:^1.2.0" + checksum: 10c0/a6cacc0a848af84f03e3f5bda7b0de75e4d0aa9ddce5517fd23ed0f31b5ddd51b2d0ff0b7e09b51f7de0f4053c7a1107117edda6b0732dca3e9e39e6c5a68c64 + languageName: node + linkType: hard + "make-dir@npm:^1.0.0": version: 1.3.0 resolution: "make-dir@npm:1.3.0" @@ -10692,6 +10980,15 @@ __metadata: languageName: node linkType: hard +"make-dir@npm:^4.0.0": + version: 4.0.0 + resolution: "make-dir@npm:4.0.0" + dependencies: + semver: "npm:^7.5.3" + checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 + languageName: node + linkType: hard + "make-fetch-happen@npm:^10.0.3, make-fetch-happen@npm:^10.2.1": version: 10.2.1 resolution: "make-fetch-happen@npm:10.2.1" @@ -12032,6 +12329,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -13101,6 +13405,20 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + +"pathval@npm:^2.0.0": + version: 2.0.0 + resolution: "pathval@npm:2.0.0" + checksum: 10c0/602e4ee347fba8a599115af2ccd8179836a63c925c23e04bd056d0674a64b39e3a081b643cc7bc0b84390517df2d800a46fcc5598d42c155fe4977095c2f77c5 + languageName: node + linkType: hard + "pdf-parse@npm:1.1.1": version: 1.1.1 resolution: "pdf-parse@npm:1.1.1" @@ -14287,10 +14605,10 @@ __metadata: languageName: node linkType: hard -"react-refresh@npm:^0.14.2": - version: 0.14.2 - resolution: "react-refresh@npm:0.14.2" - checksum: 10c0/875b72ef56b147a131e33f2abd6ec059d1989854b3ff438898e4f9310bfcc73acff709445b7ba843318a953cb9424bcc2c05af2b3d80011cee28f25aef3e2ebb +"react-refresh@npm:^0.17.0": + version: 0.17.0 + resolution: "react-refresh@npm:0.17.0" + checksum: 10c0/002cba940384c9930008c0bce26cac97a9d5682bc623112c2268ba0c155127d9c178a9a5cc2212d560088d60dfd503edd808669a25f9b377f316a32361d0b23c languageName: node linkType: hard @@ -14923,7 +15241,7 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.30.1": +"rollup@npm:^4.30.1, rollup@npm:^4.34.9": version: 4.40.0 resolution: "rollup@npm:4.40.0" dependencies: @@ -15309,6 +15627,13 @@ __metadata: languageName: node linkType: hard +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + "signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" @@ -15357,6 +15682,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^3.0.1": + version: 3.0.1 + resolution: "sirv@npm:3.0.1" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10c0/7cf64b28daa69b15f77b38b0efdd02c007b72bb3ec5f107b208ebf59f01b174ef63a1db3aca16d2df925501831f4c209be6ece3302b98765919ef5088b45bf80 + languageName: node + linkType: hard + "sitemapper@npm:^3.2.20": version: 3.2.20 resolution: "sitemapper@npm:3.2.20" @@ -15446,7 +15782,7 @@ __metadata: languageName: node linkType: hard -"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.1": +"source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.2.0, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1" checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf @@ -15596,6 +15932,13 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + "stat-mode@npm:^1.0.0": version: 1.0.0 resolution: "stat-mode@npm:1.0.0" @@ -15610,6 +15953,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^3.8.1": + version: 3.9.0 + resolution: "std-env@npm:3.9.0" + checksum: 10c0/4a6f9218aef3f41046c3c7ecf1f98df00b30a07f4f35c6d47b28329bc2531eef820828951c7d7b39a1c5eb19ad8a46e3ddfc7deb28f0a2f3ceebee11bab7ba50 + languageName: node + linkType: hard + "stream-head@npm:^3.0.0": version: 3.0.0 resolution: "stream-head@npm:3.0.0" @@ -16033,6 +16383,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.1 + resolution: "test-exclude@npm:7.0.1" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^9.0.4" + checksum: 10c0/6d67b9af4336a2e12b26a68c83308c7863534c65f27ed4ff7068a56f5a58f7ac703e8fc80f698a19bb154fd8f705cdf7ec347d9512b2c522c737269507e7b263 + languageName: node + linkType: hard + "text-encoding@npm:0.7.0": version: 0.7.0 resolution: "text-encoding@npm:0.7.0" @@ -16114,6 +16475,13 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + "tinycolor2@npm:^1.4.1, tinycolor2@npm:^1.6.0": version: 1.6.0 resolution: "tinycolor2@npm:1.6.0" @@ -16121,6 +16489,44 @@ __metadata: languageName: node linkType: hard +"tinyexec@npm:^0.3.2": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.12": + version: 0.2.12 + resolution: "tinyglobby@npm:0.2.12" + dependencies: + fdir: "npm:^6.4.3" + picomatch: "npm:^4.0.2" + checksum: 10c0/7c9be4fd3625630e262dcb19015302aad3b4ba7fc620f269313e688f2161ea8724d6cb4444baab5ef2826eb6bed72647b169a33ec8eea37501832a2526ff540f + languageName: node + linkType: hard + +"tinypool@npm:^1.0.2": + version: 1.0.2 + resolution: "tinypool@npm:1.0.2" + checksum: 10c0/31ac184c0ff1cf9a074741254fe9ea6de95026749eb2b8ec6fd2b9d8ca94abdccda731f8e102e7f32e72ed3b36d32c6975fd5f5523df3f1b6de6c3d8dfd95e63 + languageName: node + linkType: hard + +"tinyrainbow@npm:^2.0.0": + version: 2.0.0 + resolution: "tinyrainbow@npm:2.0.0" + checksum: 10c0/c83c52bef4e0ae7fb8ec6a722f70b5b6fa8d8be1c85792e829f56c0e1be94ab70b293c032dc5048d4d37cfe678f1f5babb04bdc65fd123098800148ca989184f + languageName: node + linkType: hard + +"tinyspy@npm:^3.0.2": + version: 3.0.2 + resolution: "tinyspy@npm:3.0.2" + checksum: 10c0/55ffad24e346622b59292e097c2ee30a63919d5acb7ceca87fc0d1c223090089890587b426e20054733f97a58f20af2c349fb7cc193697203868ab7ba00bcea0 + languageName: node + linkType: hard + "tldts-core@npm:^6.1.86": version: 6.1.86 resolution: "tldts-core@npm:6.1.86" @@ -16212,6 +16618,13 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10c0/4bb1fadb69c3edbef91c73ebef9d25b33bbf69afe1e37ce544d5f7d13854cda15e47132f3e0dc4cafe300ddb8578c77c50a65004d8b6e97e77934a69aa924863 + languageName: node + linkType: hard + "tough-cookie@npm:^5.1.1": version: 5.1.2 resolution: "tough-cookie@npm:5.1.2" @@ -16897,6 +17310,21 @@ __metadata: languageName: node linkType: hard +"vite-node@npm:3.1.1": + version: 3.1.1 + resolution: "vite-node@npm:3.1.1" + dependencies: + cac: "npm:^6.7.14" + debug: "npm:^4.4.0" + es-module-lexer: "npm:^1.6.0" + pathe: "npm:^2.0.3" + vite: "npm:^5.0.0 || ^6.0.0" + bin: + vite-node: vite-node.mjs + checksum: 10c0/15ee73c472ae00f042a7cee09a31355d2c0efbb2dab160377545be9ba4b980a5f4cb2841b98319d87bedf630bbbb075e6b40796b39f65610920cf3fde66fdf8d + languageName: node + linkType: hard + "vite@npm:6.2.6": version: 6.2.6 resolution: "vite@npm:6.2.6" @@ -16949,6 +17377,114 @@ __metadata: languageName: node linkType: hard +"vite@npm:^5.0.0 || ^6.0.0": + version: 6.3.2 + resolution: "vite@npm:6.3.2" + dependencies: + esbuild: "npm:^0.25.0" + fdir: "npm:^6.4.3" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.2" + postcss: "npm:^8.5.3" + rollup: "npm:^4.34.9" + tinyglobby: "npm:^0.2.12" + peerDependencies: + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: ">=1.21.0" + less: "*" + lightningcss: ^1.21.0 + sass: "*" + sass-embedded: "*" + stylus: "*" + sugarss: "*" + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/08681e83e8338f3915cee729d8296bb9cfd53f71d6796816445d58cd84a8387396a3f5f3e179c52b09e98ccf7247ec3fabb50b95b0e7f3289a619ef8bf71bd8a + languageName: node + linkType: hard + +"vitest@npm:^3.1.1": + version: 3.1.1 + resolution: "vitest@npm:3.1.1" + dependencies: + "@vitest/expect": "npm:3.1.1" + "@vitest/mocker": "npm:3.1.1" + "@vitest/pretty-format": "npm:^3.1.1" + "@vitest/runner": "npm:3.1.1" + "@vitest/snapshot": "npm:3.1.1" + "@vitest/spy": "npm:3.1.1" + "@vitest/utils": "npm:3.1.1" + chai: "npm:^5.2.0" + debug: "npm:^4.4.0" + expect-type: "npm:^1.2.0" + magic-string: "npm:^0.30.17" + pathe: "npm:^2.0.3" + std-env: "npm:^3.8.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^0.3.2" + tinypool: "npm:^1.0.2" + tinyrainbow: "npm:^2.0.0" + vite: "npm:^5.0.0 || ^6.0.0" + vite-node: "npm:3.1.1" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.1.1 + "@vitest/ui": 3.1.1 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + bin: + vitest: vitest.mjs + checksum: 10c0/680f31d2a7ca59509f837acdbacd9dff405e1b00c606d7cd29717127c6b543f186055854562c2604f74c5cd668b70174968d28feb4ed948a7e013c9477a68d50 + languageName: node + linkType: hard + "void-elements@npm:3.1.0": version: 3.1.0 resolution: "void-elements@npm:3.1.0" @@ -17096,6 +17632,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + "wicked-good-xpath@npm:1.3.0": version: 1.3.0 resolution: "wicked-good-xpath@npm:1.3.0"