Initial release of Web Tools 0.0.5
This commit is contained in:
@@ -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('无效')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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('文件为空')
|
||||
})
|
||||
})
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
:0400000001020304F2
|
||||
:02001000AABB89
|
||||
:00000001FF
|
||||
@@ -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('尺寸无效')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user