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
This commit is contained in:
one 2025-04-20 22:43:29 +08:00 committed by kangfenmao
parent 24c9a8e8f1
commit 81eab1179b
35 changed files with 3771 additions and 617 deletions

5
.gitignore vendored
View File

@ -46,3 +46,8 @@ local
.aider* .aider*
.cursorrules .cursorrules
.cursor/rules .cursor/rules
# test
coverage
.vitest-cache
vitest.config.*.timestamp-*

View File

@ -44,7 +44,12 @@
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"check:i18n": "node scripts/check-i18n.js", "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 .", "format": "prettier --write .",
"lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
@ -133,7 +138,9 @@
"@types/react-infinite-scroll-component": "^5.0.0", "@types/react-infinite-scroll-component": "^5.0.0",
"@types/tinycolor2": "^1", "@types/tinycolor2": "^1",
"@types/ws": "^8", "@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", "analytics": "^0.8.16",
"antd": "^5.22.5", "antd": "^5.22.5",
"applescript": "^1.0.0", "applescript": "^1.0.0",
@ -197,7 +204,8 @@
"tokenx": "^0.4.1", "tokenx": "^0.4.1",
"typescript": "^5.6.2", "typescript": "^5.6.2",
"uuid": "^10.0.0", "uuid": "^10.0.0",
"vite": "6.2.6" "vite": "6.2.6",
"vitest": "^3.1.1"
}, },
"resolutions": { "resolutions": {
"pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch", "pdf-parse@npm:1.1.1": "patch:pdf-parse@npm%3A1.1.1#~/.yarn/patches/pdf-parse-npm-1.1.1-04a6109b2a.patch",

View File

@ -11,7 +11,7 @@ import { translateText } from '@renderer/services/TranslateService'
import { RootState } from '@renderer/store' import { RootState } from '@renderer/store'
import type { Message, Model } from '@renderer/types' import type { Message, Model } from '@renderer/types'
import type { Assistant, Topic } from '@renderer/types' import type { Assistant, Topic } from '@renderer/types'
import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL, removeTrailingDoubleSpaces } from '@renderer/utils' import { captureScrollableDivAsBlob, captureScrollableDivAsDataURL } from '@renderer/utils'
import { import {
exportMarkdownToJoplin, exportMarkdownToJoplin,
exportMarkdownToNotion, exportMarkdownToNotion,
@ -21,6 +21,7 @@ import {
messageToMarkdown messageToMarkdown
} from '@renderer/utils/export' } from '@renderer/utils/export'
import { withMessageThought } from '@renderer/utils/formats' import { withMessageThought } from '@renderer/utils/formats'
import { removeTrailingDoubleSpaces } from '@renderer/utils/markdown'
import { Button, Dropdown, Popconfirm, Tooltip } from 'antd' import { Button, Dropdown, Popconfirm, Tooltip } from 'antd'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { clone } from 'lodash' import { clone } from 'lodash'

View File

@ -5,7 +5,8 @@ import i18n from '@renderer/i18n'
import { fetchMessagesSummary } from '@renderer/services/ApiService' import { fetchMessagesSummary } from '@renderer/services/ApiService'
import store from '@renderer/store' import store from '@renderer/store'
import { Assistant, Message, Model, Topic } from '@renderer/types' 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 dayjs from 'dayjs'
import { t } from 'i18next' import { t } from 'i18next'
import { isEmpty, remove, takeRight } from 'lodash' import { isEmpty, remove, takeRight } from 'lodash'

View File

@ -1,11 +1,10 @@
import assert from 'node:assert' import { Model } from '@renderer/types'
import { test } from 'node:test'
import { ChatCompletionMessageParam } from 'openai/resources' 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[] = [ const mockMessages: ChatCompletionMessageParam[] = [
{ role: 'user', content: 'First question' }, { role: 'user', content: 'First question' },
{ role: 'user', content: 'Additional context' }, { role: 'user', content: 'Additional context' },
@ -15,56 +14,63 @@ test('ModelMessageService', async (t) => {
{ role: 'assistant', content: 'Second answer' } { role: 'assistant', content: 'Second answer' }
] ]
await t.test('should merge successive messages with same role for deepseek-reasoner model', () => { const createModel = (id: string): Model => ({
const model = { id: 'deepseek-reasoner' } 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) const result = processReqMessages(model, mockMessages)
assert.strictEqual(result.length, 4) expect(result.length).toBe(4)
assert.deepStrictEqual(result[0], { expect(result[0]).toEqual({
role: 'user', role: 'user',
content: 'First question\nAdditional context' content: 'First question\nAdditional context'
}) })
assert.deepStrictEqual(result[1], { expect(result[1]).toEqual({
role: 'assistant', role: 'assistant',
content: 'First answer\nAdditional information' content: 'First answer\nAdditional information'
}) })
assert.deepStrictEqual(result[2], { expect(result[2]).toEqual({
role: 'user', role: 'user',
content: 'Second question' content: 'Second question'
}) })
assert.deepStrictEqual(result[3], { expect(result[3]).toEqual({
role: 'assistant', role: 'assistant',
content: 'Second answer' content: 'Second answer'
}) })
}) })
await t.test('should not merge messages for other models', () => { it('should not merge messages for other models', () => {
const model = { id: 'gpt-4' } const model = createModel('gpt-4')
const result = processReqMessages(model, mockMessages) const result = processReqMessages(model, mockMessages)
assert.strictEqual(result.length, mockMessages.length) expect(result.length).toBe(mockMessages.length)
assert.deepStrictEqual(result, mockMessages) expect(result).toEqual(mockMessages)
}) })
await t.test('should handle empty messages array', () => { it('should handle empty messages array', () => {
const model = { id: 'deepseek-reasoner' } const model = createModel('deepseek-reasoner')
const result = processReqMessages(model, []) const result = processReqMessages(model, [])
assert.strictEqual(result.length, 0) expect(result.length).toBe(0)
assert.deepStrictEqual(result, []) expect(result).toEqual([])
}) })
await t.test('should handle single message', () => { it('should handle single message', () => {
const model = { id: 'deepseek-reasoner' } const model = createModel('deepseek-reasoner')
const singleMessage = [{ role: 'user', content: 'Single message' }] const singleMessage = [{ role: 'user', content: 'Single message' }]
const result = processReqMessages(model, singleMessage) const result = processReqMessages(model, singleMessage as ChatCompletionMessageParam[])
assert.strictEqual(result.length, 1) expect(result.length).toBe(1)
assert.deepStrictEqual(result, singleMessage) expect(result).toEqual(singleMessage)
}) })
await t.test('should preserve other message properties when merging', () => { it('should preserve other message properties when merging', () => {
const model = { id: 'deepseek-reasoner' } const model = createModel('deepseek-reasoner')
const messagesWithProps = [ const messagesWithProps = [
{ {
role: 'user', role: 'user',
@ -81,8 +87,8 @@ test('ModelMessageService', async (t) => {
const result = processReqMessages(model, messagesWithProps) const result = processReqMessages(model, messagesWithProps)
assert.strictEqual(result.length, 1) expect(result.length).toBe(1)
assert.deepStrictEqual(result[0], { expect(result[0]).toEqual({
role: 'user', role: 'user',
content: 'First message\nSecond message', content: 'First message\nSecond message',
name: 'user1', name: 'user1',
@ -90,8 +96,8 @@ test('ModelMessageService', async (t) => {
}) })
}) })
await t.test('should handle alternating roles correctly', () => { it('should handle alternating roles correctly', () => {
const model = { id: 'deepseek-reasoner' } const model = createModel('deepseek-reasoner')
const alternatingMessages = [ const alternatingMessages = [
{ role: 'user', content: 'Q1' }, { role: 'user', content: 'Q1' },
{ role: 'assistant', content: 'A1' }, { role: 'assistant', content: 'A1' },
@ -101,12 +107,12 @@ test('ModelMessageService', async (t) => {
const result = processReqMessages(model, alternatingMessages) const result = processReqMessages(model, alternatingMessages)
assert.strictEqual(result.length, 4) expect(result.length).toBe(4)
assert.deepStrictEqual(result, alternatingMessages) expect(result).toEqual(alternatingMessages)
}) })
await t.test('should handle messages with empty content', () => { it('should handle messages with empty content', () => {
const model = { id: 'deepseek-reasoner' } const model = createModel('deepseek-reasoner')
const messagesWithEmpty = [ const messagesWithEmpty = [
{ role: 'user', content: 'Q1' }, { role: 'user', content: 'Q1' },
{ role: 'user', content: '' }, { role: 'user', content: '' },
@ -115,8 +121,8 @@ test('ModelMessageService', async (t) => {
const result = processReqMessages(model, messagesWithEmpty) const result = processReqMessages(model, messagesWithEmpty)
assert.strictEqual(result.length, 1) expect(result.length).toBe(1)
assert.deepStrictEqual(result[0], { expect(result[0]).toEqual({
role: 'user', role: 'user',
content: 'Q1\n\nQ2' content: 'Q1\n\nQ2'
}) })

View File

@ -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",
],
}
`;

View File

@ -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')
})
})
})

View File

@ -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<number>, 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<number>()
map.set('<all_urls>', 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()
// <all_urls>
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<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()
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])
})
})

View File

@ -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)
})
})
})

View File

@ -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('<details')
expect(messageToMarkdownWithReasoning(msg)).toContain('思考内容')
})
it('should handle <think> tag and newlines', () => {
const msg = createMessage({ role: 'assistant', content: 'hi', reasoning_content: '<think>\nA\nB', id: '6' })
expect(messageToMarkdownWithReasoning(msg)).toContain('A<br>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')
})
})
})

View File

@ -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 = `
<websearch>
<question>What is the capital of France?</question>
<question>How many people live in Paris?</question>
<links>https://en.wikipedia.org/wiki/Paris</links>
<links>https://www.paris.fr/</links>
</websearch>
`
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 = `
<knowledge>
<rewrite>This is a rewritten query</rewrite>
<question>What is artificial intelligence?</question>
<question>Who invented machine learning?</question>
</knowledge>
`
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 = `
<root>
<websearch>
<question>What is climate change?</question>
<links>https://en.wikipedia.org/wiki/Climate_change</links>
</websearch>
<knowledge>
<rewrite>climate change effects</rewrite>
<question>What are the effects of climate change?</question>
</knowledge>
</root>
`
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 = `
<websearch>
<question>Single question?</question>
</websearch>
`
const result = extractInfoFromXML(xml)
expect(result).toEqual({
websearch: {
question: ['Single question?']
}
})
})
it('should handle XML with special characters', () => {
const xml = `
<websearch>
<question>What is the meaning of &lt;div&gt; in HTML?</question>
<links>https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div</links>
</websearch>
`
const result = extractInfoFromXML(xml)
expect(result).toEqual({
websearch: {
question: ['What is the meaning of <div> in HTML?'],
links: ['https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div']
}
})
})
it('should handle invalid XML gracefully', () => {
const invalidXml = `
<websearch>
<question>Incomplete tag
<links>https://example.com</links>
</websearch>
`
// 注意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 = `
<websearch>
<question></question>
<links></links>
</websearch>
`
const result = extractInfoFromXML(xml)
expect(result).toEqual({
websearch: {
question: [''],
links: ['']
}
})
})
})
})

View File

@ -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('')
})
})
})

View File

@ -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 = '<html><head><title>Page Title</title></head><body>Content</body></html>'
expect(extractTitle(html)).toBe('Page Title')
})
it('should extract title with case insensitivity', () => {
const html = '<html><head><TITLE>Page Title</TITLE></head><body>Content</body></html>'
expect(extractTitle(html)).toBe('Page Title')
})
it('should handle HTML without title tag', () => {
const html = '<html><head></head><body>Content</body></html>'
expect(extractTitle(html)).toBeNull()
})
it('should handle empty title tag', () => {
const html = '<html><head><title></title></head><body>Content</body></html>'
expect(extractTitle(html)).toBe('')
})
it('should handle malformed HTML', () => {
const html = '<title>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'])
})
})
})

View File

@ -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()
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)
})
})
})

View File

@ -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)')
})
})
})

View File

@ -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('')
})
})
})

View File

@ -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...')
})
})
})

View File

@ -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 }}')
})
})
})

View File

@ -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)
})
})
})

View File

@ -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')
})
})
})

View File

@ -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])
})
})

View File

@ -5,11 +5,42 @@ import { getMessageTitle } from '@renderer/services/MessagesService'
import store from '@renderer/store' import store from '@renderer/store'
import { setExportState } from '@renderer/store/runtime' import { setExportState } from '@renderer/store/runtime'
import { Message, Topic } from '@renderer/types' 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 { markdownToBlocks } from '@tryfabric/martian'
import dayjs from 'dayjs' import dayjs from 'dayjs'
//TODO: 添加对思考内容的支持 //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) => { export const messageToMarkdown = (message: Message) => {
const { forceDollarMathInMarkdown } = store.getState().settings const { forceDollarMathInMarkdown } = store.getState().settings
const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant' const roleText = message.role === 'user' ? '🧑‍💻 User' : '🤖 Assistant'

View File

@ -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()
}

View File

@ -38,11 +38,20 @@ $$
} }
export function extractTitle(html: string): string | null { export function extractTitle(html: string): string | null {
// 处理标准闭合的标题标签
const titleRegex = /<title>(.*?)<\/title>/i const titleRegex = /<title>(.*?)<\/title>/i
const match = html.match(titleRegex) const match = html.match(titleRegex)
if (match && match[1]) { if (match) {
return match[1].trim() 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 return null
@ -191,20 +200,17 @@ export function withGenerateImage(message: Message) {
return message return message
} }
const cleanImgContent = message.content // 替换图片语法,保留其他内容
.replace(imagePattern, '') let cleanContent = message.content.replace(imagePattern, '').trim()
.replace(/\n\s*\n/g, '\n')
.trim()
// 检查是否有下载链接
const downloadPattern = new RegExp(`\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`) const downloadPattern = new RegExp(`\\[[^\\]]*\\]\\((.*?)\\s*("(?:.*[^"])")?\\s*\\)`)
const downloadMatches = cleanImgContent.match(downloadPattern) const downloadMatches = cleanContent.match(downloadPattern)
let cleanContent = cleanImgContent // 如果有下载链接,只保留图片前的内容
if (downloadMatches) { if (downloadMatches) {
cleanContent = cleanImgContent const contentBeforeImage = message.content.split(imageMatches[0])[0].trim()
.replace(downloadPattern, '') cleanContent = contentBeforeImage
.replace(/\n\s*\n/g, '\n')
.trim()
} }
message = { message = {
@ -233,5 +239,5 @@ export function addImageFileToContents(messages: Message[]) {
images: imageFiles images: imageFiles
} }
return messages.map((message) => (message.role === 'assistant' ? updatedAssistantMessage : message)) return messages.map((message) => (message.id === lastAssistantMessage.id ? updatedAssistantMessage : message))
} }

View File

@ -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')
})
}

View File

@ -1,42 +1,22 @@
import i18n from '@renderer/i18n'
import { Model } from '@renderer/types' import { Model } from '@renderer/types'
import { KB, MB } from '@shared/config/constant'
import { ModalFuncProps } from 'antd/es/modal/interface' import { ModalFuncProps } from 'antd/es/modal/interface'
import imageCompression from 'browser-image-compression'
import * as htmlToImage from 'html-to-image'
// @ts-ignore next-line` // @ts-ignore next-line`
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { classNames } from './style' /**
*
* @param fn
* @returns Promise<void>
*/
export const runAsyncFunction = async (fn: () => void) => { export const runAsyncFunction = async (fn: () => void) => {
await fn() await fn()
} }
/** /**
* json * Promise
* @param str * @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) => { export const delay = (seconds: number) => {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
@ -66,78 +46,6 @@ export const waitAsyncFunction = (fn: () => Promise<any>, interval = 200, stopTi
export const uuid = () => uuidv4() 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) { export function isFreeModel(model: Model) {
return (model.id + model.name).toLocaleLowerCase().includes('free') return (model.id + model.name).toLocaleLowerCase().includes('free')
} }
@ -152,6 +60,11 @@ export async function isDev() {
return !isProd return !isProd
} }
/**
*
* @param error
* @returns string
*/
export function getErrorMessage(error: any) { export function getErrorMessage(error: any) {
if (!error) { if (!error) {
return '' return ''
@ -181,49 +94,6 @@ export function removeSpecialCharacters(str: string) {
return str.replace(/[\n"]/g, '').replace(/[\p{M}\p{P}]/gu, '') 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 * is valid proxy url
* @param url proxy url * @param url proxy url
@ -233,6 +103,11 @@ export const isValidProxyUrl = (url: string) => {
return url.includes('://') return url.includes('://')
} }
/**
* JavaScript
* @param url URL
* @returns Promise<void> Promise
*/
export function loadScript(url: string) { export function loadScript(url: string) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement('script') const script = document.createElement('script')
@ -246,171 +121,11 @@ export function loadScript(url: string) {
}) })
} }
export function convertMathFormula(input) { /**
if (!input) return input * URL
* @param url URL
let result = input * @returns boolean URL true false
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')
})
}
export function hasPath(url: string): boolean { export function hasPath(url: string): boolean {
try { try {
const parsedUrl = new URL(url) 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' * @param v1
} * @param v2
* @returns number 1 v1 v2-1 v1 v20
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)
}
export const compareVersions = (v1: string, v2: string): number => { export const compareVersions = (v1: string, v2: string): number => {
const v1Parts = v1.split('.').map(Number) const v1Parts = v1.split('.').map(Number)
const v2Parts = v2.split('.').map(Number) const v2Parts = v2.split('.').map(Number)
@ -458,6 +159,11 @@ export function isMiniWindow() {
return window.location.hash === '#/mini' return window.location.hash === '#/mini'
} }
/**
*
* @param params
* @returns Promise<boolean> true false
*/
export function modalConfirm(params: ModalFuncProps) { export function modalConfirm(params: ModalFuncProps) {
return new Promise((resolve) => { return new Promise((resolve) => {
window.modal.confirm({ 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] *
* @param obj
if (title.includes('。')) { * @param key
title = title.split('。')[0] * @returns boolean true false
} 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 function hasObjectKey(obj: any, key: string) { export function hasObjectKey(obj: any, key: string) {
if (typeof obj !== 'object' || obj === null) { if (typeof obj !== 'object' || obj === null) {
return false return false
@ -524,4 +212,9 @@ export function getMcpConfigSampleFromReadme(readme: string) {
return null return null
} }
export { classNames } export * from './file'
export * from './image'
export * from './json'
export * from './naming'
export * from './sort'
export * from './style'

View File

@ -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
}
}

View File

@ -384,6 +384,6 @@ function isValidUrl(url: string): boolean {
* @returns * @returns
*/ */
export function cleanLinkCommas(text: string): string { export function cleanLinkCommas(text: string): string {
// 匹配两个 Markdown 链接之间的逗号(可能包含空格) // 匹配两个 Markdown 链接之间的英文逗号(可能包含空格)
return text.replace(/\]\([^)]+\)\s*,\s*\[/g, ']()[') return text.replace(/\]\(([^)]+)\)\s*,\s*\[/g, ']($1)[')
} }

View File

@ -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 = [ export const MARKDOWN_ALLOWED_TAGS = [
'style', 'style',
'p', 'p',
@ -81,3 +61,49 @@ export const sanitizeSchema = {
a: ['href', 'target', 'rel'] 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, '')
}

View File

@ -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 + '...'
}

View File

@ -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)
}

54
vitest.config.ts Normal file
View File

@ -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
}
}
}
})

590
yarn.lock
View File

@ -67,7 +67,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@ampproject/remapping@npm:^2.2.0": "@ampproject/remapping@npm:^2.2.0, @ampproject/remapping@npm:^2.3.0":
version: 2.3.0 version: 2.3.0
resolution: "@ampproject/remapping@npm:2.3.0" resolution: "@ampproject/remapping@npm:2.3.0"
dependencies: dependencies:
@ -296,7 +296,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 7.26.10
resolution: "@babel/core@npm:7.26.10" resolution: "@babel/core@npm:7.26.10"
dependencies: dependencies:
@ -415,7 +415,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 7.27.0
resolution: "@babel/parser@npm:7.27.0" resolution: "@babel/parser@npm:7.27.0"
dependencies: dependencies:
@ -505,7 +505,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 7.27.0
resolution: "@babel/types@npm:7.27.0" resolution: "@babel/types@npm:7.27.0"
dependencies: dependencies:
@ -515,6 +515,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@buttercup/fetch@npm:^0.2.1":
version: 0.2.1 version: 0.2.1
resolution: "@buttercup/fetch@npm:0.2.1" resolution: "@buttercup/fetch@npm:0.2.1"
@ -1677,6 +1684,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@jimp/bmp@npm:^0.16.13":
version: 0.16.13 version: 0.16.13
resolution: "@jimp/bmp@npm:0.16.13" resolution: "@jimp/bmp@npm:0.16.13"
@ -2133,7 +2147,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 0.3.25
resolution: "@jridgewell/trace-mapping@npm:0.3.25" resolution: "@jridgewell/trace-mapping@npm:0.3.25"
dependencies: dependencies:
@ -3004,6 +3018,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@rc-component/async-validator@npm:^5.0.3":
version: 5.0.4 version: 5.0.4
resolution: "@rc-component/async-validator@npm:5.0.4" resolution: "@rc-component/async-validator@npm:5.0.4"
@ -4067,18 +4088,142 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@vitejs/plugin-react@npm:4.3.4": "@vitejs/plugin-react@npm:^4.4.1":
version: 4.3.4 version: 4.4.1
resolution: "@vitejs/plugin-react@npm:4.3.4" resolution: "@vitejs/plugin-react@npm:4.4.1"
dependencies: 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-self": "npm:^7.25.9"
"@babel/plugin-transform-react-jsx-source": "npm:^7.25.9" "@babel/plugin-transform-react-jsx-source": "npm:^7.25.9"
"@types/babel__core": "npm:^7.20.5" "@types/babel__core": "npm:^7.20.5"
react-refresh: "npm:^0.14.2" react-refresh: "npm:^0.17.0"
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 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 languageName: node
linkType: hard linkType: hard
@ -4195,7 +4340,9 @@ __metadata:
"@types/react-infinite-scroll-component": "npm:^5.0.0" "@types/react-infinite-scroll-component": "npm:^5.0.0"
"@types/tinycolor2": "npm:^1" "@types/tinycolor2": "npm:^1"
"@types/ws": "npm:^8" "@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" "@xyflow/react": "npm:^12.4.4"
adm-zip: "npm:^0.5.16" adm-zip: "npm:^0.5.16"
analytics: "npm:^0.8.16" analytics: "npm:^0.8.16"
@ -4284,6 +4431,7 @@ __metadata:
undici: "npm:^7.4.0" undici: "npm:^7.4.0"
uuid: "npm:^10.0.0" uuid: "npm:^10.0.0"
vite: "npm:6.2.6" vite: "npm:6.2.6"
vitest: "npm:^3.1.1"
webdav: "npm:^5.8.0" webdav: "npm:^5.8.0"
ws: "npm:^8.18.1" ws: "npm:^8.18.1"
zipread: "npm:^1.3.3" zipread: "npm:^1.3.3"
@ -4768,6 +4916,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "ast-types@npm:^0.13.4":
version: 0.13.4 version: 0.13.4
resolution: "ast-types@npm:0.13.4" resolution: "ast-types@npm:0.13.4"
@ -5381,6 +5536,19 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "chalk@npm:2.4.2":
version: 2.4.2 version: 2.4.2
resolution: "chalk@npm:2.4.2" resolution: "chalk@npm:2.4.2"
@ -5465,6 +5633,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "chokidar@npm:^4.0.0":
version: 4.0.3 version: 4.0.3
resolution: "chokidar@npm:4.0.3" resolution: "chokidar@npm:4.0.3"
@ -6263,6 +6438,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "deep-extend@npm:^0.6.0":
version: 0.6.0 version: 0.6.0
resolution: "deep-extend@npm:0.6.0" resolution: "deep-extend@npm:0.6.0"
@ -7002,6 +7184,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1 version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1" resolution: "es-object-atoms@npm:1.1.1"
@ -7634,6 +7823,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "esutils@npm:^2.0.2":
version: 2.0.3 version: 2.0.3
resolution: "esutils@npm:2.0.3" resolution: "esutils@npm:2.0.3"
@ -7730,6 +7928,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "exponential-backoff@npm:^3.1.1":
version: 3.1.2 version: 3.1.2
resolution: "exponential-backoff@npm:3.1.2" resolution: "exponential-backoff@npm:3.1.2"
@ -7928,6 +8133,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "fetch-blob@npm:^3.1.2, fetch-blob@npm:^3.1.4":
version: 3.2.0 version: 3.2.0
resolution: "fetch-blob@npm:3.2.0" resolution: "fetch-blob@npm:3.2.0"
@ -7955,6 +8172,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "file-entry-cache@npm:^8.0.0":
version: 8.0.0 version: 8.0.0
resolution: "file-entry-cache@npm:8.0.0" resolution: "file-entry-cache@npm:8.0.0"
@ -8112,7 +8336,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"flatted@npm:^3.2.9": "flatted@npm:^3.2.9, flatted@npm:^3.3.3":
version: 3.3.3 version: 3.3.3
resolution: "flatted@npm:3.3.3" resolution: "flatted@npm:3.3.3"
checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538
@ -8557,7 +8781,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 10.4.5
resolution: "glob@npm:10.4.5" resolution: "glob@npm:10.4.5"
dependencies: dependencies:
@ -9132,6 +9356,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "html-parse-stringify@npm:^3.0.1":
version: 3.0.1 version: 3.0.1
resolution: "html-parse-stringify@npm:3.0.1" resolution: "html-parse-stringify@npm:3.0.1"
@ -9854,6 +10085,45 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "jackspeak@npm:^3.1.2":
version: 3.4.3 version: 3.4.3
resolution: "jackspeak@npm:3.4.3" resolution: "jackspeak@npm:3.4.3"
@ -10612,6 +10882,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "lowercase-keys@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "lowercase-keys@npm:2.0.0" resolution: "lowercase-keys@npm:2.0.0"
@ -10674,7 +10951,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"magic-string@npm:^0.30.10": "magic-string@npm:^0.30.10, magic-string@npm:^0.30.17":
version: 0.30.17 version: 0.30.17
resolution: "magic-string@npm:0.30.17" resolution: "magic-string@npm:0.30.17"
dependencies: dependencies:
@ -10683,6 +10960,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "make-dir@npm:^1.0.0":
version: 1.3.0 version: 1.3.0
resolution: "make-dir@npm:1.3.0" resolution: "make-dir@npm:1.3.0"
@ -10692,6 +10980,15 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "make-fetch-happen@npm:^10.0.3, make-fetch-happen@npm:^10.2.1":
version: 10.2.1 version: 10.2.1
resolution: "make-fetch-happen@npm:10.2.1" resolution: "make-fetch-happen@npm:10.2.1"
@ -12032,6 +12329,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "ms@npm:2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "ms@npm:2.0.0" resolution: "ms@npm:2.0.0"
@ -13101,6 +13405,20 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "pdf-parse@npm:1.1.1":
version: 1.1.1 version: 1.1.1
resolution: "pdf-parse@npm:1.1.1" resolution: "pdf-parse@npm:1.1.1"
@ -14287,10 +14605,10 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-refresh@npm:^0.14.2": "react-refresh@npm:^0.17.0":
version: 0.14.2 version: 0.17.0
resolution: "react-refresh@npm:0.14.2" resolution: "react-refresh@npm:0.17.0"
checksum: 10c0/875b72ef56b147a131e33f2abd6ec059d1989854b3ff438898e4f9310bfcc73acff709445b7ba843318a953cb9424bcc2c05af2b3d80011cee28f25aef3e2ebb checksum: 10c0/002cba940384c9930008c0bce26cac97a9d5682bc623112c2268ba0c155127d9c178a9a5cc2212d560088d60dfd503edd808669a25f9b377f316a32361d0b23c
languageName: node languageName: node
linkType: hard linkType: hard
@ -14923,7 +15241,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"rollup@npm:^4.30.1": "rollup@npm:^4.30.1, rollup@npm:^4.34.9":
version: 4.40.0 version: 4.40.0
resolution: "rollup@npm:4.40.0" resolution: "rollup@npm:4.40.0"
dependencies: dependencies:
@ -15309,6 +15627,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "signal-exit@npm:^3.0.0, signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.7":
version: 3.0.7 version: 3.0.7
resolution: "signal-exit@npm:3.0.7" resolution: "signal-exit@npm:3.0.7"
@ -15357,6 +15682,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "sitemapper@npm:^3.2.20":
version: 3.2.20 version: 3.2.20
resolution: "sitemapper@npm:3.2.20" resolution: "sitemapper@npm:3.2.20"
@ -15446,7 +15782,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 1.2.1
resolution: "source-map-js@npm:1.2.1" resolution: "source-map-js@npm:1.2.1"
checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf
@ -15596,6 +15932,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "stat-mode@npm:^1.0.0":
version: 1.0.0 version: 1.0.0
resolution: "stat-mode@npm:1.0.0" resolution: "stat-mode@npm:1.0.0"
@ -15610,6 +15953,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "stream-head@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "stream-head@npm:3.0.0" resolution: "stream-head@npm:3.0.0"
@ -16033,6 +16383,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "text-encoding@npm:0.7.0":
version: 0.7.0 version: 0.7.0
resolution: "text-encoding@npm:0.7.0" resolution: "text-encoding@npm:0.7.0"
@ -16114,6 +16475,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "tinycolor2@npm:^1.4.1, tinycolor2@npm:^1.6.0":
version: 1.6.0 version: 1.6.0
resolution: "tinycolor2@npm:1.6.0" resolution: "tinycolor2@npm:1.6.0"
@ -16121,6 +16489,44 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "tldts-core@npm:^6.1.86":
version: 6.1.86 version: 6.1.86
resolution: "tldts-core@npm:6.1.86" resolution: "tldts-core@npm:6.1.86"
@ -16212,6 +16618,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "tough-cookie@npm:^5.1.1":
version: 5.1.2 version: 5.1.2
resolution: "tough-cookie@npm:5.1.2" resolution: "tough-cookie@npm:5.1.2"
@ -16897,6 +17310,21 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "vite@npm:6.2.6":
version: 6.2.6 version: 6.2.6
resolution: "vite@npm:6.2.6" resolution: "vite@npm:6.2.6"
@ -16949,6 +17377,114 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "void-elements@npm:3.1.0":
version: 3.1.0 version: 3.1.0
resolution: "void-elements@npm:3.1.0" resolution: "void-elements@npm:3.1.0"
@ -17096,6 +17632,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "wicked-good-xpath@npm:1.3.0":
version: 1.3.0 version: 1.3.0
resolution: "wicked-good-xpath@npm:1.3.0" resolution: "wicked-good-xpath@npm:1.3.0"