Initial release of Web Tools 0.0.5

This commit is contained in:
mid
2026-08-07 21:51:15 +08:00
commit fa14e9e5b9
71 changed files with 6365 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import {
bytesToHex,
bytesToText,
decodeBase64,
encodeBase64,
formatBytes,
parseHex,
textToBytes,
} from '../src/lib/bytes'
describe('parseHex', () => {
it('accepts contiguous hex, separators and optional 0x prefixes', () => {
const expected = '00112233445566778899AABBCCDDEEFF'
expect(bytesToHex(parseHex(expected), '')).toBe(expected)
expect(bytesToHex(parseHex('00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF'), '')).toBe(expected)
expect(bytesToHex(parseHex('00,11,22,33,44,55,66,77,88,99,AA,BB,CC,DD,EE,FF'), '')).toBe(expected)
expect(bytesToHex(parseHex('0x00 0x11 0x22 0x33 0x44 0x55 0x66 0x77 0x88 0x99 0xAA 0xBB 0xCC 0xDD 0xEE 0xFF'), '')).toBe(expected)
expect(bytesToHex(parseHex(`0x${expected}`), '')).toBe(expected)
})
it('reports invalid characters, odd length and exact byte count', () => {
expect(() => parseHex('GG')).toThrow('无效')
expect(() => parseHex('10x2')).toThrow('无效')
expect(() => parseHex('AA0xBB')).toThrow('无效')
expect(() => parseHex('ABC')).toThrow('偶数')
expect(() => parseHex('0011', 16)).toThrow('当前为 2 字节')
})
})
describe('UTF-8 and Base64', () => {
it('round-trips Unicode text', () => {
const source = '你好,Web Tools'
expect(bytesToText(textToBytes(source))).toBe(source)
expect(bytesToText(decodeBase64(encodeBase64(textToBytes(source))))).toBe(source)
})
it('formats decoded binary as continuous hex and source-code bytes', () => {
const bytes = decodeBase64('SJ87vWNeyh907uXD4bRteQ==')
expect(formatBytes(bytes)).toBe('489F3BBD635ECA1F74EEE5C3E1B46D79')
expect(formatBytes(bytes, { format: 'byte-array', uppercase: false })).toBe('0x48, 0x9f, 0x3b, 0xbd, 0x63, 0x5e, 0xca, 0x1f, 0x74, 0xee, 0xe5, 0xc3, 0xe1, 0xb4, 0x6d, 0x79')
})
it('supports URL-safe Base64 without padding', () => {
const bytes = Uint8Array.from([251, 255, 239])
expect(encodeBase64(bytes, true)).toBe('-__v')
expect(decodeBase64('-__v', true)).toEqual(bytes)
})
it('rejects invalid Base64', () => {
expect(() => decodeBase64('abcde')).toThrow('长度')
expect(() => decodeBase64('ab*c')).toThrow('无效')
expect(() => decodeBase64('Zh==')).toThrow('无效')
expect(() => decodeBase64('Zm9=')).toThrow('无效')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import defaultNavigation from '../shared/default-navigation.json'
import catalog from '../shared/tool-catalog.json'
import {
assertToolRegistryConsistency,
categories,
iconComponents,
toolComponents,
tools,
} from '../src/tools/registry'
describe('tool catalog consistency', () => {
it('keeps catalog categories, icons and lazy components registered', () => {
expect(() => assertToolRegistryConsistency()).not.toThrow()
expect(new Set(categories.map(({ id }) => id)).size).toBe(categories.length)
expect(new Set(tools.map(({ id }) => id)).size).toBe(tools.length)
expect(tools.every((tool) => Boolean(iconComponents[tool.icon]))).toBe(true)
expect(Object.keys(toolComponents).sort()).toEqual(tools.map(({ id }) => id).sort())
})
it('lists every catalog category and tool exactly once in default navigation', () => {
const navigationCategoryIds = defaultNavigation.categories.map(({ id }) => id)
const navigationToolIds = defaultNavigation.categories.flatMap(({ tools: ids }) => ids)
expect(navigationCategoryIds).toEqual(catalog.categories.map(({ id }) => id))
expect(navigationToolIds.sort()).toEqual(catalog.tools.map(({ id }) => id).sort())
expect(new Set(navigationToolIds).size).toBe(navigationToolIds.length)
})
})
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { bytesToHex, parseHex } from '../src/lib/bytes'
import { aes128Cmac } from '../src/lib/cmac'
describe('AES-128-CMAC RFC 4493 vectors', () => {
const key = parseHex('2B7E151628AED2A6ABF7158809CF4F3C')
const vectors = [
['', 'BB1D6929E95937287FA37D129B756746'],
['6BC1BEE22E409F96E93D7E117393172A', '070A16B46B4D4144F79BDD9DD04A287C'],
['6BC1BEE22E409F96E93D7E117393172AAE2D8A57', '7D85449EA6EA19C823A7BF78837DFADE'],
['6BC1BEE22E409F96E93D7E117393172AAE2D8A571E03AC9C9EB76FAC45AF8E5130C81C46A35CE411', 'DFA66747DE9AE63030CA32611497C827'],
]
it.each(vectors)('matches the expected tag', (message, expected) => {
expect(bytesToHex(aes128Cmac(key, parseHex(message)), '')).toBe(expected)
})
})
+123
View File
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { calculateCrc, calculateCrcChunks, CRC_PRESETS, formatCrc } from '../src/lib/crc'
import { calculateCrcReport } from '../src/lib/crcWorker'
import { getFirmwareFormat, parseFirmwareContent } from '../src/lib/firmware'
import { concatenateBlocks, parseIntelHex } from '../src/lib/intelHex'
import { textToBytes } from '../src/lib/bytes'
function intelHexRecord(address: number, data: number[]): string {
const bytes = [data.length, address >> 8, address & 0xff, 0, ...data]
const checksum = (-bytes.reduce((sum, byte) => sum + byte, 0)) & 0xff
return `:${[...bytes, checksum].map((byte) => byte.toString(16).padStart(2, '0')).join('').toUpperCase()}`
}
describe('CRC', () => {
it('matches standard check values for 123456789', () => {
const input = textToBytes('123456789')
for (const preset of CRC_PRESETS) {
expect(calculateCrc(input, preset), preset.name).toBe(preset.check)
expect(formatCrc(preset.check, preset.width)).toHaveLength(Math.ceil(preset.width / 4))
}
})
it('supports CRC-64 / ECMA-182 parameters', () => {
expect(calculateCrc(textToBytes('123456789'), {
width: 64,
polynomial: 0x42f0e1eba9ea3693n,
initialValue: 0n,
inputReflected: false,
outputReflected: false,
xorOut: 0n,
})).toBe(0x6c40df5f0b497347n)
})
it('supports reflected CRC-64 / XZ parameters', () => {
expect(calculateCrc(textToBytes('123456789'), {
width: 64,
polynomial: 0x42f0e1eba9ea3693n,
initialValue: 0xffffffffffffffffn,
inputReflected: true,
outputReflected: true,
xorOut: 0xffffffffffffffffn,
})).toBe(0x995dc9bbdf1939fan)
})
it('calculates chunked data as one continuous byte stream', () => {
const preset = CRC_PRESETS[0]
const chunks = [textToBytes('123'), textToBytes('456'), textToBytes('789')]
expect(calculateCrcChunks(chunks, preset)).toBe(calculateCrc(textToBytes('123456789'), preset))
})
it('reports both the combined CRC and each block CRC', () => {
const parameters = CRC_PRESETS.find((preset) => preset.id === 'crc-32-iso-hdlc')!
const blocks = [
{ startAddress: 0x1000, data: textToBytes('1234') },
{ startAddress: 0x2000, data: textToBytes('56789') },
]
const report = calculateCrcReport(blocks, parameters)
expect(report.combinedResult).toBe(formatCrc(parameters.check, parameters.width))
expect(report.blockResults).toEqual([
{ startAddress: 0x1000, endAddress: 0x1003, length: 4, crc: formatCrc(calculateCrc(blocks[0].data, parameters), 32) },
{ startAddress: 0x2000, endAddress: 0x2004, length: 5, crc: formatCrc(calculateCrc(blocks[1].data, parameters), 32) },
])
})
})
describe('Intel HEX', () => {
it('validates records and separates non-contiguous data into blocks', () => {
const parsed = parseIntelHex(':0400000001020304F2\n:02001000AABB89\n:00000001FF\n')
expect(parsed.totalBytes).toBe(6)
expect(parsed.blocks).toHaveLength(2)
expect(parsed.blocks[0]).toEqual({ startAddress: 0, data: Uint8Array.from([1, 2, 3, 4]) })
expect(parsed.blocks[1]).toEqual({ startAddress: 0x10, data: Uint8Array.from([0xaa, 0xbb]) })
expect(concatenateBlocks(parsed.blocks)).toEqual(Uint8Array.from([1, 2, 3, 4, 0xaa, 0xbb]))
})
it('supports extended addresses and reports a missing EOF record', () => {
const parsed = parseIntelHex(':020000040001F9\n:040000001122334452\n')
expect(parsed.blocks[0].startAddress).toBe(0x10000)
expect(parsed.warnings).toEqual(['文件缺少 EOF 记录,已按现有数据计算'])
})
it('rejects invalid checksums and conflicting overlapping data', () => {
expect(() => parseIntelHex(':0400000001020304F3\n:00000001FF')).toThrow('校验和错误')
expect(() => parseIntelHex(':020000000102FB\n:02000100FF03FB\n:00000001FF')).toThrow('数据冲突')
})
it('merges out-of-order and identical overlapping records without changing bytes', () => {
const parsed = parseIntelHex([
intelHexRecord(2, [3, 4]),
intelHexRecord(0, [1, 2, 3]),
':00000001FF',
].join('\n'))
expect(parsed.blocks).toEqual([{ startAddress: 0, data: Uint8Array.from([1, 2, 3, 4]) }])
})
it('enforces configurable data-record and block limits', () => {
const records = [intelHexRecord(0, [1]), intelHexRecord(2, [2]), ':00000001FF'].join('\n')
expect(() => parseIntelHex(records, { maxDataRecords: 1 })).toThrow('数据记录不能超过 1 条')
expect(() => parseIntelHex(records, { maxBlocks: 1 })).toThrow('数据块不能超过 1 个')
expect(() => parseIntelHex(records, { maxBlocks: 0 })).toThrow('必须是正整数')
})
})
describe('firmware parsing', () => {
it('detects supported extensions case-insensitively', () => {
expect(getFirmwareFormat('firmware.HEX')).toBe('Intel HEX')
expect(getFirmwareFormat('firmware.ihx')).toBe('Intel HEX')
expect(getFirmwareFormat('firmware.bin')).toBe('BIN')
expect(() => getFirmwareFormat('firmware.txt')).toThrow('请选择')
})
it('parses Intel HEX and BIN content with strict content types', () => {
const hex = parseFirmwareContent('firmware.hex', `${intelHexRecord(0, [0xaa, 0xbb])}\n:00000001FF`)
expect(hex.totalBytes).toBe(2)
expect(hex.blocks[0].data).toEqual(Uint8Array.from([0xaa, 0xbb]))
const bin = parseFirmwareContent('firmware.bin', Uint8Array.from([1, 2, 3]))
expect(bin).toMatchObject({ format: 'BIN', totalBytes: 3, warnings: [] })
expect(() => parseFirmwareContent('firmware.hex', Uint8Array.of(1))).toThrow('必须是文本')
expect(() => parseFirmwareContent('firmware.bin', '')).toThrow('必须是二进制')
expect(() => parseFirmwareContent('firmware.bin', new Uint8Array())).toThrow('文件为空')
})
})
+3
View File
@@ -0,0 +1,3 @@
:0400000001020304F2
:02001000AABB89
:00000001FF
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { normalizeIcoSizes, packIco } from '../src/lib/ico'
import {
MAX_PNG_DIMENSION,
MAX_PNG_FILE_SIZE,
MAX_PNG_PIXELS,
parsePngHeader,
PNG_HEADER_LENGTH,
PNG_SIGNATURE,
} from '../src/lib/png'
function pngHeader(width: number, height: number): Uint8Array {
const bytes = new Uint8Array(PNG_HEADER_LENGTH)
bytes.set(PNG_SIGNATURE)
const view = new DataView(bytes.buffer)
view.setUint32(8, 13, false)
bytes.set([0x49, 0x48, 0x44, 0x52], 12)
view.setUint32(16, width, false)
view.setUint32(20, height, false)
bytes.set([8, 6, 0, 0, 0], 24)
return bytes
}
describe('packIco', () => {
it('writes a valid ICO directory with PNG frame offsets', () => {
const first = Uint8Array.from([1, 2, 3])
const second = Uint8Array.from([4, 5, 6, 7])
const output = packIco([{ size: 32, data: first }, { size: 256, data: second }])
const view = new DataView(output.buffer)
expect(view.getUint16(0, true)).toBe(0)
expect(view.getUint16(2, true)).toBe(1)
expect(view.getUint16(4, true)).toBe(2)
expect(view.getUint8(6)).toBe(32)
expect(view.getUint8(22)).toBe(0)
expect(view.getUint32(14, true)).toBe(3)
expect(view.getUint32(18, true)).toBe(38)
expect(view.getUint32(34, true)).toBe(41)
expect(Array.from(output.slice(38))).toEqual([1, 2, 3, 4, 5, 6, 7])
})
it('rejects missing or invalid frames', () => {
expect(() => packIco([])).toThrow('至少')
expect(() => packIco([{ size: 512, data: Uint8Array.of(1) }])).toThrow('尺寸无效')
})
it('normalizes sizes before allocating canvases', () => {
expect(normalizeIcoSizes([256, 16, 16, 32])).toEqual([16, 32, 256])
expect(() => normalizeIcoSizes([])).toThrow('至少')
expect(() => normalizeIcoSizes([0])).toThrow('尺寸无效')
expect(() => normalizeIcoSizes([16.5])).toThrow('尺寸无效')
expect(() => normalizeIcoSizes(Array.from({ length: 256 }, () => 16))).toThrow('数量过多')
})
})
describe('PNG header validation', () => {
it('reads valid IHDR dimensions', () => {
expect(parsePngHeader(pngHeader(512, 256))).toEqual({ width: 512, height: 256, pixels: 131_072 })
})
it('rejects invalid signatures, truncated headers and missing IHDR chunks', () => {
expect(() => parsePngHeader(new Uint8Array(PNG_HEADER_LENGTH))).toThrow('有效的 PNG')
expect(() => parsePngHeader(pngHeader(1, 1).slice(0, 24), PNG_HEADER_LENGTH)).toThrow('文件头不完整')
const missingIhdr = pngHeader(1, 1)
missingIhdr[12] = 0
expect(() => parsePngHeader(missingIhdr)).toThrow('IHDR')
})
it('enforces compressed size, dimensions and decoded pixel limits', () => {
expect(() => parsePngHeader(pngHeader(1, 1), MAX_PNG_FILE_SIZE + 1)).toThrow('32 MiB')
expect(() => parsePngHeader(pngHeader(MAX_PNG_DIMENSION + 1, 1))).toThrow('宽高')
expect(() => parsePngHeader(pngHeader(8_000, 4_001))).toThrow('总像素')
expect(() => parsePngHeader(pngHeader(0, 1))).toThrow('尺寸无效')
})
})
+76
View File
@@ -0,0 +1,76 @@
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
normalizeNavigation,
validateNavigation,
writeJsonAtomic,
} from '../server/navigation.mjs'
const temporaryDirectories = []
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
const catalog = {
categories: [{ id: 'encoding' }, { id: 'crypto' }],
tools: [
{ id: 'hex', category: 'encoding' },
{ id: 'base64', category: 'encoding' },
{ id: 'cmac', category: 'crypto' },
],
}
const defaults = {
version: 1,
categories: [
{ id: 'encoding', tools: ['hex', 'base64'] },
{ id: 'crypto', tools: ['cmac'] },
],
}
describe('navigation config', () => {
it('preserves custom order and appends tools added in a later release', () => {
const current = { version: 1, categories: [{ id: 'encoding', tools: ['base64'] }] }
expect(normalizeNavigation(current, catalog, defaults)).toEqual({
version: 1,
categories: [
{ id: 'encoding', tools: ['base64', 'hex'] },
{ id: 'crypto', tools: ['cmac'] },
],
})
})
it('rejects duplicate or incomplete configurations', () => {
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex', 'hex'] }] }, catalog)).toThrow('重复')
expect(() => validateNavigation({ version: 1, categories: [{ id: 'encoding', tools: ['hex'] }, { id: 'crypto', tools: ['cmac'] }] }, catalog)).toThrow('全部')
})
it('writes valid JSON through an atomic replacement', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-navigation-'))
temporaryDirectories.push(directory)
const filePath = path.join(directory, 'navigation.json')
await writeJsonAtomic(filePath, defaults)
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual(defaults)
})
it('closes the handle and removes the temporary file when writing fails', async () => {
const calls = []
const failure = new Error('disk full')
const operations = {
mkdir: async () => undefined,
open: async (temporaryPath) => ({
writeFile: async () => { throw failure },
sync: async () => undefined,
close: async () => { calls.push('close') },
temporaryPath,
}),
rename: async () => { calls.push('rename') },
unlink: async (temporaryPath) => { calls.push(`unlink:${temporaryPath}`) },
}
await expect(writeJsonAtomic('/virtual/navigation.json', defaults, operations)).rejects.toBe(failure)
expect(calls[0]).toBe('close')
expect(calls[1]).toMatch(/^unlink:\/virtual\/navigation\.json\..+\.tmp$/)
expect(calls).not.toContain('rename')
})
})
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import {
moveCategoryBy,
moveToolBy,
moveToolToCategory,
relocateTool,
reorderCategory,
} from '../src/lib/navigationOrder'
import type { NavigationConfig } from '../src/types'
const navigation: NavigationConfig = {
version: 1,
categories: [
{ id: 'one', tools: ['a', 'b', 'c'] },
{ id: 'two', tools: ['d', 'e'] },
{ id: 'three', tools: [] },
],
}
describe('navigation ordering', () => {
it('moves categories upward and downward without mutating the input', () => {
expect(moveCategoryBy(navigation, 0, 1).categories.map(({ id }) => id)).toEqual(['two', 'one', 'three'])
expect(reorderCategory(navigation, 2, 0).categories.map(({ id }) => id)).toEqual(['three', 'one', 'two'])
expect(navigation.categories.map(({ id }) => id)).toEqual(['one', 'two', 'three'])
})
it('moves tools upward and downward within a category', () => {
expect(moveToolBy(navigation, 0, 0, 1).categories[0].tools).toEqual(['b', 'a', 'c'])
expect(moveToolBy(navigation, 0, 2, -1).categories[0].tools).toEqual(['a', 'c', 'b'])
})
it('moves tools across categories and appends them at the end', () => {
const crossList = relocateTool(navigation, { categoryIndex: 0, toolIndex: 1 }, { categoryIndex: 1, toolIndex: 1 })
expect(crossList.categories[0].tools).toEqual(['a', 'c'])
expect(crossList.categories[1].tools).toEqual(['d', 'b', 'e'])
const appended = moveToolToCategory(navigation, 'a', 0, 'three')
expect(appended.categories[0].tools).toEqual(['b', 'c'])
expect(appended.categories[2].tools).toEqual(['a'])
})
it('clamps end insertion and ignores invalid source positions', () => {
expect(relocateTool(navigation, { categoryIndex: 0, toolIndex: 0 }, { categoryIndex: 1, toolIndex: 99 }).categories[1].tools).toEqual(['d', 'e', 'a'])
expect(relocateTool(navigation, { categoryIndex: 9, toolIndex: 0 }, { categoryIndex: 1, toolIndex: 0 })).toEqual(navigation)
expect(moveCategoryBy(navigation, 0, -1)).toEqual(navigation)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { formatBytes } from '../src/lib/bytes'
import { secureRandomBytes, type RandomSource } from '../src/lib/random'
describe('secure random bytes', () => {
it('uses the supplied cryptographic source and preserves requested length', () => {
let next = 0
const source: RandomSource = {
getRandomValues<T extends ArrayBufferView>(array: T): T {
const bytes = new Uint8Array(array.buffer, array.byteOffset, array.byteLength)
bytes.forEach((_, index) => { bytes[index] = next; next += 1 })
return array
},
}
expect(secureRandomBytes(4, source)).toEqual(Uint8Array.from([0, 1, 2, 3]))
expect(() => secureRandomBytes(0, source)).toThrow('1 至 65536')
})
it('formats the same bytes for OpenSSL and source-code styles', () => {
const bytes = Uint8Array.from([0x0a, 0xbc, 0xff])
expect(formatBytes(bytes)).toBe('0ABCFF')
expect(formatBytes(bytes, { format: 'spaced-hex' })).toBe('0A BC FF')
expect(formatBytes(bytes, { format: 'byte-array', uppercase: false })).toBe('0x0a, 0xbc, 0xff')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { formatBytes, parseHex } from '../src/lib/bytes'
import { base64UrlToHex, formatPem, generateRsaKeyPair } from '../src/lib/rsa'
describe('RSA key generation', () => {
it('formats DER and JWK integer values', () => {
expect(formatPem(Uint8Array.from([0, 1, 2, 3]), 'PUBLIC KEY')).toBe('-----BEGIN PUBLIC KEY-----\nAAECAw==\n-----END PUBLIC KEY-----\n')
expect(base64UrlToHex('AQAB')).toBe('010001')
})
it('generates an exportable 2048-bit key pair using a cryptographic provider', async () => {
const result = await generateRsaKeyPair(2048)
expect(result.publicKeyPem).toMatch(/^-----BEGIN PUBLIC KEY-----/)
expect(result.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/)
expect(result.modulusHex).toHaveLength(512)
expect(result.exponentHex).toBe('010001')
expect(formatBytes(parseHex(result.exponentHex), { format: 'byte-array' })).toBe('0x01, 0x00, 0x01')
expect(['Web Crypto', 'node-forge']).toContain(result.source)
}, 20_000)
})
+115
View File
@@ -0,0 +1,115 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createWebToolsServer, MAX_JSON_BODY_SIZE } from '../server/index.mjs'
const temporaryDirectories = []
const servers = []
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => server.close(resolve))))
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
})
const catalog = {
categories: [{ id: 'encoding' }, { id: 'crypto' }],
tools: [
{ id: 'hex', category: 'encoding' },
{ id: 'cmac', category: 'crypto' },
],
}
const defaults = {
version: 1,
categories: [
{ id: 'encoding', tools: ['hex'] },
{ id: 'crypto', tools: ['cmac'] },
],
}
async function createFixtureServer() {
const directory = await mkdtemp(path.join(tmpdir(), 'web-tools-server-'))
temporaryDirectories.push(directory)
const staticRoot = path.join(directory, 'dist')
const dataDir = path.join(directory, 'data')
await writeFile(path.join(directory, 'placeholder'), '')
await mkdir(staticRoot, { recursive: true })
await writeFile(path.join(staticRoot, 'index.html'), '<!doctype html><title>Web Tools</title>')
await writeFile(path.join(staticRoot, 'app.js'), 'console.log("ok")')
const server = await createWebToolsServer({ staticRoot, dataDir, catalog, defaults, logger: { error: () => undefined } })
servers.push(server)
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
return { baseUrl: `http://127.0.0.1:${address.port}`, dataDir }
}
describe('HTTP server', () => {
it('serves direct SPA navigation but returns 404 for missing assets', async () => {
const { baseUrl } = await createFixtureServer()
const page = await fetch(`${baseUrl}/tools/base64`, { headers: { Accept: 'text/html' } })
expect(page.status).toBe(200)
expect(await page.text()).toContain('<title>Web Tools</title>')
const asset = await fetch(`${baseUrl}/assets/definitely-missing.js`)
expect(asset.status).toBe(404)
expect(asset.headers.get('content-type')).toContain('text/plain')
const extensionlessAsset = await fetch(`${baseUrl}/assets/chunk`, { headers: { Accept: 'application/javascript' } })
expect(extensionlessAsset.status).toBe(404)
})
it('returns no-store JSON health and API responses', async () => {
const { baseUrl } = await createFixtureServer()
const health = await fetch(`${baseUrl}/healthz`)
expect(await health.json()).toEqual({ status: 'ok' })
expect(health.headers.get('cache-control')).toBe('no-store')
const missingApi = await fetch(`${baseUrl}/api/missing`)
expect(missingApi.status).toBe(404)
expect(missingApi.headers.get('cache-control')).toBe('no-store')
})
it('creates, reads and updates navigation through the API', async () => {
const { baseUrl, dataDir } = await createFixtureServer()
const initial = await fetch(`${baseUrl}/api/navigation`)
expect(await initial.json()).toEqual(defaults)
const updated = { version: 1, categories: [...defaults.categories].reverse() }
const saved = await fetch(`${baseUrl}/api/navigation`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updated),
})
expect(saved.status).toBe(200)
expect(await saved.json()).toEqual(updated)
expect(JSON.parse(await readFile(path.join(dataDir, 'navigation.json'), 'utf8'))).toEqual(updated)
})
it('rejects invalid, oversized and cross-site navigation writes', async () => {
const { baseUrl } = await createFixtureServer()
const invalid = await fetch(`${baseUrl}/api/navigation`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '{invalid',
})
expect(invalid.status).toBe(400)
const oversized = await fetch(`${baseUrl}/api/navigation`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: ' '.repeat(MAX_JSON_BODY_SIZE + 1),
})
expect(oversized.status).toBe(400)
expect((await oversized.json()).error).toContain('过大')
const crossSite = await fetch(`${baseUrl}/api/navigation`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Sec-Fetch-Site': 'cross-site' },
body: JSON.stringify(defaults),
})
expect(crossSite.status).toBe(403)
})
})
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
MAX_HIGHLIGHTED_SYNTAX_LENGTH,
MAX_HIGHLIGHTED_SYNTAX_TOKENS,
tokenizeJson,
tokenizeSyntaxForDisplay,
tokenizeXml,
} from '../src/lib/syntax'
describe('syntax tokenizers', () => {
it('classifies JSON tokens without changing content', () => {
const source = '{\n "name": "Web Tools",\n "count": 3,\n "local": true,\n "value": null\n}'
const tokens = tokenizeJson(source)
expect(tokens.map((token) => token.text).join('')).toBe(source)
expect(tokens.some((token) => token.type === 'key' && token.text === '"name"')).toBe(true)
expect(tokens.some((token) => token.type === 'string' && token.text === '"Web Tools"')).toBe(true)
expect(tokens.some((token) => token.type === 'number' && token.text === '3')).toBe(true)
expect(tokens.some((token) => token.type === 'literal' && token.text === 'true')).toBe(true)
})
it('classifies XML tags, attributes, values and comments without changing content', () => {
const source = '<?xml version="1.0"?>\n<!-- tools -->\n<tools><tool id="hex">ASCII</tool></tools>'
const tokens = tokenizeXml(source)
expect(tokens.map((token) => token.text).join('')).toBe(source)
expect(tokens.some((token) => token.type === 'tag' && token.text === 'tool')).toBe(true)
expect(tokens.some((token) => token.type === 'attribute' && token.text === 'id')).toBe(true)
expect(tokens.some((token) => token.type === 'value' && token.text === '"hex"')).toBe(true)
expect(tokens.some((token) => token.type === 'comment')).toBe(true)
expect(tokens.some((token) => token.type === 'meta')).toBe(true)
})
it('uses plain text for results that would create excessive DOM nodes', () => {
expect(tokenizeSyntaxForDisplay('{"ok":true}', 'json')).toBeDefined()
expect(tokenizeSyntaxForDisplay('x'.repeat(MAX_HIGHLIGHTED_SYNTAX_LENGTH + 1), 'json')).toBeUndefined()
const tokenHeavy = Array.from({ length: Math.floor(MAX_HIGHLIGHTED_SYNTAX_TOKENS / 2) + 1 }, () => '<a/>').join('')
expect(tokenHeavy.length).toBeLessThan(MAX_HIGHLIGHTED_SYNTAX_LENGTH)
expect(tokenizeSyntaxForDisplay(tokenHeavy, 'xml')).toBeUndefined()
})
})