发布 0.3.0 并增加 RSA 签名验证
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
formatBytes,
|
||||
parseHex,
|
||||
textToBytes,
|
||||
textToBytesLimited,
|
||||
} from '../src/lib/bytes'
|
||||
|
||||
describe('parseHex', () => {
|
||||
@@ -25,6 +26,20 @@ describe('parseHex', () => {
|
||||
expect(() => parseHex('AA0xBB')).toThrow('无效')
|
||||
expect(() => parseHex('ABC')).toThrow('偶数')
|
||||
expect(() => parseHex('0011', 16)).toThrow('当前为 2 字节')
|
||||
expect(() => parseHex(',AA')).toThrow('片段缺少')
|
||||
expect(() => parseHex('AA,')).toThrow('片段缺少')
|
||||
})
|
||||
|
||||
it('supports bounded parsing without allocating oversized output', () => {
|
||||
expect(parseHex('00 11', { maxBytes: 2 })).toEqual(Uint8Array.from([0, 0x11]))
|
||||
expect(() => parseHex('00 11 22', { maxBytes: 2, limitLabel: '测试数据' })).toThrow('测试数据不能超过')
|
||||
expect(() => parseHex('0'.repeat(2_000), { maxInputChars: 100 })).toThrow('输入不能超过')
|
||||
})
|
||||
|
||||
it('encodes bounded UTF-8 text before allocating the result', () => {
|
||||
expect(textToBytesLimited('你好', 6)).toEqual(textToBytes('你好'))
|
||||
expect(() => textToBytesLimited('你好', 5)).toThrow('不能超过 5 字节')
|
||||
expect(() => textToBytesLimited('abc', 10, 2, '测试数据')).toThrow('输入不能超过')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -33,4 +33,12 @@ describe('form field identity', () => {
|
||||
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps automatic PSS salt correction separate from user edits', async () => {
|
||||
const source = await readFile(path.join(projectRoot, 'src/tools/SignatureVerifyTool.vue'), 'utf8')
|
||||
expect(source).toContain("const scheme = ref<RsaSignatureScheme>('pss')")
|
||||
expect(source).toContain('@input="customizeSaltLength"')
|
||||
expect(source).toContain('function customizeSaltLength()')
|
||||
expect(source).not.toMatch(/watch\s*\(\s*saltLength\b/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
constants,
|
||||
createPublicKey,
|
||||
generateKeyPairSync,
|
||||
sign,
|
||||
type KeyObject,
|
||||
} from 'node:crypto'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { encodeBase64, formatBytes } from '../src/lib/bytes'
|
||||
import { parseSignatureFile } from '../src/lib/signatureVerificationFile'
|
||||
import {
|
||||
decodeSignature,
|
||||
defaultPssSaltLength,
|
||||
hashLength,
|
||||
maximumPssSaltLength,
|
||||
parseRsaPublicKeyParameters,
|
||||
parseRsaPublicKeyPem,
|
||||
verifyRsaSignature,
|
||||
type ParsedRsaPublicKey,
|
||||
type SignatureHashAlgorithm,
|
||||
} from '../src/lib/signatureVerification'
|
||||
|
||||
let privateKey: KeyObject
|
||||
let publicKey: KeyObject
|
||||
let spkiPem = ''
|
||||
let pkcs1Pem = ''
|
||||
let certificatePem = ''
|
||||
let parsedSpki: ParsedRsaPublicKey
|
||||
const message = new TextEncoder().encode('Web Tools RSA signature verification')
|
||||
|
||||
function nodeHash(hash: SignatureHashAlgorithm): string {
|
||||
return hash.toLowerCase().replace('-', '')
|
||||
}
|
||||
|
||||
function createSignature(
|
||||
data: Uint8Array,
|
||||
hash: SignatureHashAlgorithm,
|
||||
scheme: 'pkcs1-v1_5' | 'pss',
|
||||
saltLength = hashLength(hash),
|
||||
): Uint8Array {
|
||||
return sign(nodeHash(hash), data, {
|
||||
key: privateKey,
|
||||
padding: scheme === 'pss' ? constants.RSA_PKCS1_PSS_PADDING : constants.RSA_PKCS1_PADDING,
|
||||
...(scheme === 'pss' ? { saltLength } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const generated = generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicExponent: 0x10001,
|
||||
})
|
||||
privateKey = generated.privateKey
|
||||
publicKey = generated.publicKey
|
||||
spkiPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
|
||||
pkcs1Pem = publicKey.export({ type: 'pkcs1', format: 'pem' }).toString()
|
||||
parsedSpki = await parseRsaPublicKeyPem(spkiPem)
|
||||
|
||||
const { default: forge } = await import('node-forge')
|
||||
const forgePrivateKey = forge.pki.privateKeyFromPem(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString())
|
||||
const forgePublicKey = forge.pki.publicKeyFromPem(spkiPem)
|
||||
const certificate = forge.pki.createCertificate()
|
||||
certificate.publicKey = forgePublicKey
|
||||
certificate.serialNumber = '01AB'
|
||||
certificate.validity.notBefore = new Date('2025-01-01T00:00:00.000Z')
|
||||
certificate.validity.notAfter = new Date('2030-01-01T00:00:00.000Z')
|
||||
certificate.setSubject([{ name: 'commonName', value: 'Web Tools Test' }])
|
||||
certificate.setIssuer([{ name: 'commonName', value: 'Web Tools Test' }])
|
||||
certificate.sign(forgePrivateKey, forge.md.sha256.create())
|
||||
certificatePem = forge.pki.certificateToPem(certificate)
|
||||
}, 20_000)
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('RSA public-key parsing', () => {
|
||||
it('parses SPKI and PKCS#1 PEM with a stable SPKI fingerprint', async () => {
|
||||
const [spki, pkcs1] = await Promise.all([
|
||||
parseRsaPublicKeyPem(spkiPem),
|
||||
parseRsaPublicKeyPem(pkcs1Pem),
|
||||
])
|
||||
|
||||
expect(spki.source).toBe('SPKI')
|
||||
expect(pkcs1.source).toBe('PKCS#1')
|
||||
expect(spki.modulusBits).toBe(2048)
|
||||
expect(spki.exponentHex).toBe('010001')
|
||||
expect(pkcs1.fingerprintSha256).toBe(spki.fingerprintSha256)
|
||||
expect(createPublicKey({ key: Buffer.from(spki.spkiDer), type: 'spki', format: 'der' }).asymmetricKeyType).toBe('rsa')
|
||||
})
|
||||
|
||||
it('extracts an RSA key and display-only metadata from one X.509 certificate', async () => {
|
||||
const parsed = await parseRsaPublicKeyPem(certificatePem)
|
||||
expect(parsed.source).toBe('X.509 certificate')
|
||||
expect(parsed.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(parsed.certificate).toMatchObject({
|
||||
subject: 'CN=Web Tools Test',
|
||||
serialNumber: '01AB',
|
||||
notBefore: '2025-01-01T00:00:00.000Z',
|
||||
notAfter: '2030-01-01T00:00:00.000Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the same public key from N/E Hex parameters', async () => {
|
||||
const jwk = publicKey.export({ format: 'jwk' })
|
||||
if (!jwk.n || !jwk.e) throw new Error('Node did not export RSA JWK parameters')
|
||||
const fromParameters = await parseRsaPublicKeyParameters(
|
||||
Buffer.from(jwk.n, 'base64url').toString('hex'),
|
||||
Buffer.from(jwk.e, 'base64url').toString('hex'),
|
||||
)
|
||||
expect(fromParameters.source).toBe('N/E parameters')
|
||||
expect(fromParameters.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
})
|
||||
|
||||
it('accepts an odd-length Hex public exponent and pads it on the left', async () => {
|
||||
const fromOddLengthExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '10001')
|
||||
const fromEvenLengthExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '010001')
|
||||
const fromPrefixedExponent = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '0x10001')
|
||||
const fromLeadingZeros = await parseRsaPublicKeyParameters(parsedSpki.modulusHex, '00010001')
|
||||
|
||||
expect(fromOddLengthExponent.exponentHex).toBe('010001')
|
||||
expect(fromOddLengthExponent.exponentDecimal).toBe('65537')
|
||||
expect(fromOddLengthExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(fromEvenLengthExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(fromPrefixedExponent.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
expect(fromLeadingZeros.fingerprintSha256).toBe(parsedSpki.fingerprintSha256)
|
||||
})
|
||||
|
||||
it('rejects ambiguous, private, malformed and out-of-policy key input', async () => {
|
||||
await expect(parseRsaPublicKeyPem(`${spkiPem}${pkcs1Pem}`)).rejects.toThrow('一个完整的 PEM 块')
|
||||
await expect(parseRsaPublicKeyPem(privateKey.export({ type: 'pkcs8', format: 'pem' }).toString())).rejects.toThrow('仅支持')
|
||||
await expect(parseRsaPublicKeyPem('-----BEGIN PUBLIC KEY-----\nAA==\n-----END PUBLIC KEY-----')).rejects.toThrow('格式无效')
|
||||
await expect(parseRsaPublicKeyParameters('AA', '02')).rejects.toThrow('RSA 模数必须')
|
||||
await expect(parseRsaPublicKeyParameters(`${parsedSpki.modulusHex.slice(0, -1)}2`, '010001')).rejects.toThrow('模数 N 必须是奇数')
|
||||
})
|
||||
})
|
||||
|
||||
describe('RSA signature verification', () => {
|
||||
for (const hash of ['SHA-256', 'SHA-384', 'SHA-512'] as const) {
|
||||
it(`verifies PKCS#1 v1.5 with ${hash} and rejects changed data`, async () => {
|
||||
const signature = createSignature(message, hash, 'pkcs1-v1_5')
|
||||
const valid = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pkcs1-v1_5', hash })
|
||||
const invalid = await verifyRsaSignature(parsedSpki, new Uint8Array([...message, 0]), signature, { scheme: 'pkcs1-v1_5', hash })
|
||||
expect(valid.valid).toBe(true)
|
||||
expect(invalid.valid).toBe(false)
|
||||
expect(valid.dataHash).toHaveLength(hashLength(hash) * 2)
|
||||
})
|
||||
}
|
||||
|
||||
for (const hash of ['SHA-256', 'SHA-384', 'SHA-512'] as const) {
|
||||
it(`verifies RSA-PSS with ${hash} and an explicit salt length`, async () => {
|
||||
const saltLength = hashLength(hash)
|
||||
const signature = createSignature(message, hash, 'pss', saltLength)
|
||||
const valid = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash, saltLength })
|
||||
const wrongSalt = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash, saltLength: Math.max(0, saltLength - 1) })
|
||||
expect(valid.valid).toBe(true)
|
||||
expect(wrongSalt.valid).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
it('supports an empty message and reports a corrupted signature as invalid', async () => {
|
||||
const empty = new Uint8Array()
|
||||
const signature = createSignature(empty, 'SHA-256', 'pkcs1-v1_5')
|
||||
const corrupted = Uint8Array.from(signature)
|
||||
corrupted[corrupted.length - 1] ^= 1
|
||||
await expect(verifyRsaSignature(parsedSpki, empty, signature, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.resolves.toMatchObject({ valid: true, dataBytes: 0 })
|
||||
await expect(verifyRsaSignature(parsedSpki, empty, corrupted, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.resolves.toMatchObject({ valid: false })
|
||||
})
|
||||
|
||||
it('verifies the exact Intel HEX block-header byte stream', async () => {
|
||||
const intelHex = ':020000040001F9\n:040000001122334452\n:02001000AABB89\n:00000001FF\n'
|
||||
const bytes = new TextEncoder().encode(intelHex)
|
||||
const file = {
|
||||
name: 'firmware.hex',
|
||||
size: bytes.length,
|
||||
text: async () => intelHex,
|
||||
arrayBuffer: async () => bytes.slice().buffer,
|
||||
} as File
|
||||
const parsed = await parseSignatureFile(file, 'intel-hex-block-header', 'big-endian')
|
||||
const signature = createSignature(parsed.data, 'SHA-256', 'pkcs1-v1_5')
|
||||
|
||||
await expect(verifyRsaSignature(parsedSpki, parsed.data, signature, { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.resolves.toMatchObject({ valid: true, dataBytes: 22 })
|
||||
})
|
||||
|
||||
it('validates signature length and the RSA-PSS salt boundary before verification', async () => {
|
||||
const maximum = maximumPssSaltLength(parsedSpki.modulusBits, 'SHA-512')
|
||||
await expect(verifyRsaSignature(parsedSpki, message, new Uint8Array(255), { scheme: 'pkcs1-v1_5', hash: 'SHA-256' }))
|
||||
.rejects.toThrow('签名长度必须为 256 字节')
|
||||
await expect(verifyRsaSignature(parsedSpki, message, new Uint8Array(256), { scheme: 'pss', hash: 'SHA-512', saltLength: maximum + 1 }))
|
||||
.rejects.toThrow(`0 至 ${maximum}`)
|
||||
})
|
||||
|
||||
it('keeps the default RSA-PSS salt valid for a 1024-bit key with SHA-512', () => {
|
||||
expect(defaultPssSaltLength(1024, 'SHA-512')).toBe(62)
|
||||
expect(defaultPssSaltLength(2048, 'SHA-512')).toBe(64)
|
||||
})
|
||||
|
||||
it('verifies RSA-PSS SHA-512 with the maximum valid salt on a 1024-bit key', async () => {
|
||||
const generated = generateKeyPairSync('rsa', { modulusLength: 1024, publicExponent: 0x10001 })
|
||||
const shortKey = await parseRsaPublicKeyPem(generated.publicKey.export({ type: 'spki', format: 'pem' }).toString())
|
||||
const saltLength = defaultPssSaltLength(shortKey.modulusBits, 'SHA-512')
|
||||
const signature = sign('sha512', message, {
|
||||
key: generated.privateKey,
|
||||
padding: constants.RSA_PKCS1_PSS_PADDING,
|
||||
saltLength,
|
||||
})
|
||||
await expect(verifyRsaSignature(shortKey, message, signature, { scheme: 'pss', hash: 'SHA-512', saltLength }))
|
||||
.resolves.toMatchObject({ valid: true })
|
||||
})
|
||||
|
||||
it('uses node-forge when SubtleCrypto is unavailable', async () => {
|
||||
const getRandomValues = globalThis.crypto.getRandomValues.bind(globalThis.crypto)
|
||||
const signature = createSignature(message, 'SHA-256', 'pss', 32)
|
||||
vi.stubGlobal('crypto', { getRandomValues })
|
||||
const verification = await verifyRsaSignature(parsedSpki, message, signature, { scheme: 'pss', hash: 'SHA-256', saltLength: 32 })
|
||||
expect(verification).toMatchObject({ valid: true, provider: 'node-forge' })
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('signature decoding', () => {
|
||||
it('decodes strict Hex, Base64 and Base64 URL representations', () => {
|
||||
const bytes = Uint8Array.from([0xFB, 0xEF, 0xFF])
|
||||
expect(decodeSignature(formatBytes(bytes), 'hex')).toEqual(bytes)
|
||||
expect(decodeSignature(encodeBase64(bytes), 'base64')).toEqual(bytes)
|
||||
expect(decodeSignature(encodeBase64(bytes, true), 'base64url')).toEqual(bytes)
|
||||
expect(() => decodeSignature('--__', 'base64')).toThrow('Base64 URL 字符')
|
||||
expect(() => decodeSignature('++//', 'base64url')).toThrow('标准 Base64 字符')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSignatureFile } from '../src/lib/signatureVerificationFile'
|
||||
|
||||
const intelHex = ':020000040001F9\n:040000001122334452\n:02001000AABB89\n:00000001FF\n'
|
||||
|
||||
function testFile(name: string, bytes: Uint8Array): File {
|
||||
return {
|
||||
name,
|
||||
size: bytes.length,
|
||||
arrayBuffer: async () => bytes.slice().buffer,
|
||||
text: async () => new TextDecoder().decode(bytes),
|
||||
} as File
|
||||
}
|
||||
|
||||
describe('signature verification file semantics', () => {
|
||||
it('keeps every byte in raw-file mode, including an empty file', async () => {
|
||||
const bytes = new TextEncoder().encode(intelHex)
|
||||
const raw = await parseSignatureFile(testFile('firmware.hex', bytes), 'raw-file')
|
||||
const empty = await parseSignatureFile(testFile('empty.bin', new Uint8Array()), 'raw-file')
|
||||
expect(raw.data).toEqual(bytes)
|
||||
expect(raw.blockCount).toBeUndefined()
|
||||
expect(empty.dataBytes).toBe(0)
|
||||
})
|
||||
|
||||
it('parses and concatenates only Intel HEX effective data without gap filling', async () => {
|
||||
const parsed = await parseSignatureFile(testFile('firmware.hex', new TextEncoder().encode(intelHex)), 'intel-hex')
|
||||
expect(parsed.blockCount).toBe(2)
|
||||
expect(parsed.dataBytes).toBe(6)
|
||||
expect([...parsed.data]).toEqual([0x11, 0x22, 0x33, 0x44, 0xAA, 0xBB])
|
||||
expect(parsed).toMatchObject({ effectiveDataBytes: 6, headerBytes: 0 })
|
||||
})
|
||||
|
||||
it('prepends an 8-byte address and length header to every block', async () => {
|
||||
const file = testFile('firmware.hex', new TextEncoder().encode(intelHex))
|
||||
const bigEndian = await parseSignatureFile(file, 'intel-hex-block-header', 'big-endian')
|
||||
const littleEndian = await parseSignatureFile(file, 'intel-hex-block-header', 'little-endian')
|
||||
|
||||
expect(bigEndian).toMatchObject({ dataBytes: 22, effectiveDataBytes: 6, headerBytes: 16, blockCount: 2 })
|
||||
expect([...bigEndian.data]).toEqual([
|
||||
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x11, 0x22, 0x33, 0x44,
|
||||
0x00, 0x01, 0x00, 0x10, 0x00, 0x00, 0x00, 0x02, 0xAA, 0xBB,
|
||||
])
|
||||
expect([...littleEndian.data]).toEqual([
|
||||
0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x11, 0x22, 0x33, 0x44,
|
||||
0x10, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0xAA, 0xBB,
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects invalid modes and malformed Intel HEX', async () => {
|
||||
await expect(parseSignatureFile(testFile('bad.hex', new TextEncoder().encode(':00000001FE\n')), 'intel-hex')).rejects.toThrow()
|
||||
await expect(parseSignatureFile(testFile('data.bin', Uint8Array.from([1])), 'automatic' as never)).rejects.toThrow('文件数据模式无效')
|
||||
await expect(parseSignatureFile(testFile('firmware.hex', new TextEncoder().encode(intelHex)), 'intel-hex-block-header', 'middle-endian' as never)).rejects.toThrow('字节序')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { useTransactionalFileWorker } from '../src/composables/useTransactionalFileWorker'
|
||||
|
||||
type TestResponse = {
|
||||
type: 'parsed'
|
||||
id: number
|
||||
value: string
|
||||
} | {
|
||||
type: 'calculated'
|
||||
id: number
|
||||
value: string
|
||||
} | {
|
||||
type: 'error'
|
||||
id: number
|
||||
operation: 'parse' | 'calculate'
|
||||
error: string
|
||||
}
|
||||
|
||||
class FakeWorker {
|
||||
onmessage: ((event: MessageEvent<TestResponse>) => void) | null = null
|
||||
onerror: ((event: ErrorEvent) => void) | null = null
|
||||
onmessageerror: ((event: MessageEvent) => void) | null = null
|
||||
messages: Array<Record<string, unknown>> = []
|
||||
terminated = false
|
||||
|
||||
postMessage(message: Record<string, unknown>) {
|
||||
this.messages.push(message)
|
||||
}
|
||||
|
||||
terminate() {
|
||||
this.terminated = true
|
||||
}
|
||||
|
||||
respond(response: TestResponse) {
|
||||
this.onmessage?.({ data: response } as MessageEvent<TestResponse>)
|
||||
}
|
||||
|
||||
fail() {
|
||||
this.onerror?.({ preventDefault: vi.fn() } as unknown as ErrorEvent)
|
||||
}
|
||||
}
|
||||
|
||||
function file(name: string): File {
|
||||
return { name, size: 1 } as File
|
||||
}
|
||||
|
||||
function createHarness() {
|
||||
const workers: FakeWorker[] = []
|
||||
const parsed: string[] = []
|
||||
const calculated: string[] = []
|
||||
const parseErrors: string[] = []
|
||||
const calculationErrors: string[] = []
|
||||
const pendingWorkerErrors: boolean[] = []
|
||||
const activeWorkerError = vi.fn()
|
||||
const manager = useTransactionalFileWorker<Record<string, unknown>, TestResponse>({
|
||||
createWorker: () => {
|
||||
const worker = new FakeWorker()
|
||||
workers.push(worker)
|
||||
return worker as unknown as Worker
|
||||
},
|
||||
onParsed: (response, selected) => parsed.push(`${selected.name}:${response.value}`),
|
||||
onCalculated: (response) => calculated.push(response.value),
|
||||
onParseError: (message) => parseErrors.push(message),
|
||||
onCalculationError: (message) => calculationErrors.push(message),
|
||||
onPendingWorkerError: (hasCurrentFile) => pendingWorkerErrors.push(hasCurrentFile),
|
||||
onActiveWorkerError: activeWorkerError,
|
||||
})
|
||||
return {
|
||||
manager,
|
||||
workers,
|
||||
parsed,
|
||||
calculated,
|
||||
parseErrors,
|
||||
calculationErrors,
|
||||
pendingWorkerErrors,
|
||||
activeWorkerError,
|
||||
}
|
||||
}
|
||||
|
||||
describe('transactional file Worker', () => {
|
||||
it('commits a parsed candidate and calculates with its Worker', () => {
|
||||
const harness = createHarness()
|
||||
expect(harness.manager.parseFile(file('first.hex'))).toBe(true)
|
||||
expect(harness.manager.parsing.value).toBe(true)
|
||||
expect(harness.workers[0].messages[0]).toMatchObject({ type: 'parse', id: 1 })
|
||||
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'ready' })
|
||||
expect(harness.manager.parsing.value).toBe(false)
|
||||
expect(harness.parsed).toEqual(['first.hex:ready'])
|
||||
|
||||
expect(harness.manager.calculate({ parameters: 'crc-32' })).toBe('started')
|
||||
expect(harness.manager.calculating.value).toBe(true)
|
||||
expect(harness.workers[0].messages[1]).toEqual({ type: 'calculate', id: 2, parameters: 'crc-32' })
|
||||
harness.workers[0].respond({ type: 'calculated', id: 2, value: 'result' })
|
||||
expect(harness.calculated).toEqual(['result'])
|
||||
expect(harness.manager.calculating.value).toBe(false)
|
||||
})
|
||||
|
||||
it('supports an explicit parse-request payload without changing default callers', () => {
|
||||
const workers: FakeWorker[] = []
|
||||
const manager = useTransactionalFileWorker<Record<string, unknown>, TestResponse>({
|
||||
createWorker: () => {
|
||||
const worker = new FakeWorker()
|
||||
workers.push(worker)
|
||||
return worker as unknown as Worker
|
||||
},
|
||||
createParseRequest: (id, selected) => ({ type: 'parse', id, file: selected, mode: 'intel-hex' }),
|
||||
onParsed: vi.fn(),
|
||||
onCalculated: vi.fn(),
|
||||
onParseError: vi.fn(),
|
||||
onCalculationError: vi.fn(),
|
||||
onPendingWorkerError: vi.fn(),
|
||||
onActiveWorkerError: vi.fn(),
|
||||
})
|
||||
|
||||
const selected = file('firmware.hex')
|
||||
manager.parseFile(selected)
|
||||
expect(workers[0].messages[0]).toEqual({ type: 'parse', id: 1, file: selected, mode: 'intel-hex' })
|
||||
})
|
||||
|
||||
it('preserves the active Worker when a replacement parse fails', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'first' })
|
||||
|
||||
harness.manager.parseFile(file('invalid.hex'))
|
||||
harness.workers[1].respond({ type: 'error', id: 2, operation: 'parse', error: 'bad checksum' })
|
||||
expect(harness.parseErrors).toEqual(['bad checksum'])
|
||||
expect(harness.workers[0].terminated).toBe(false)
|
||||
expect(harness.workers[1].terminated).toBe(true)
|
||||
expect(harness.manager.calculate({})).toBe('started')
|
||||
})
|
||||
|
||||
it('atomically replaces the active Worker and ignores its stale result', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'first' })
|
||||
harness.manager.calculate({})
|
||||
|
||||
harness.manager.parseFile(file('second.hex'))
|
||||
harness.workers[1].respond({ type: 'parsed', id: 3, value: 'second' })
|
||||
expect(harness.workers[0].terminated).toBe(true)
|
||||
expect(harness.parsed).toEqual(['first.hex:first', 'second.hex:second'])
|
||||
expect(harness.manager.calculating.value).toBe(false)
|
||||
|
||||
harness.workers[0].respond({ type: 'calculated', id: 2, value: 'stale' })
|
||||
expect(harness.calculated).toEqual([])
|
||||
})
|
||||
|
||||
it('distinguishes pending Worker failures with and without a current file', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].fail()
|
||||
expect(harness.pendingWorkerErrors).toEqual([false])
|
||||
|
||||
harness.manager.parseFile(file('current.hex'))
|
||||
harness.workers[1].respond({ type: 'parsed', id: 2, value: 'current' })
|
||||
harness.manager.parseFile(file('replacement.hex'))
|
||||
harness.workers[2].fail()
|
||||
expect(harness.pendingWorkerErrors).toEqual([false, true])
|
||||
expect(harness.workers[1].terminated).toBe(false)
|
||||
})
|
||||
|
||||
it('terminates active and pending Workers and ignores late responses', () => {
|
||||
const harness = createHarness()
|
||||
harness.manager.parseFile(file('first.hex'))
|
||||
harness.workers[0].respond({ type: 'parsed', id: 1, value: 'first' })
|
||||
harness.manager.parseFile(file('second.hex'))
|
||||
harness.manager.destroyWorkers()
|
||||
|
||||
expect(harness.workers.every((worker) => worker.terminated)).toBe(true)
|
||||
harness.workers[1].respond({ type: 'parsed', id: 2, value: 'late' })
|
||||
expect(harness.parsed).toEqual(['first.hex:first'])
|
||||
expect(harness.manager.parsing.value).toBe(false)
|
||||
expect(harness.manager.calculating.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reports Worker construction failure without entering a busy state', () => {
|
||||
const pendingWorkerErrors: boolean[] = []
|
||||
const manager = useTransactionalFileWorker<Record<string, unknown>, TestResponse>({
|
||||
createWorker: () => { throw new Error('unsupported') },
|
||||
onParsed: vi.fn(),
|
||||
onCalculated: vi.fn(),
|
||||
onParseError: vi.fn(),
|
||||
onCalculationError: vi.fn(),
|
||||
onPendingWorkerError: (hasCurrentFile) => pendingWorkerErrors.push(hasCurrentFile),
|
||||
onActiveWorkerError: vi.fn(),
|
||||
})
|
||||
|
||||
expect(manager.parseFile(file('first.hex'))).toBe(false)
|
||||
expect(pendingWorkerErrors).toEqual([false])
|
||||
expect(manager.parsing.value).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user