Merge development

This commit is contained in:
2026-08-10 02:48:12 +00:00
parent 941719e4e6
commit f793655cd9
22 changed files with 3240 additions and 733 deletions
+813
View File
@@ -0,0 +1,813 @@
import { expect, test } from 'vitest';
import {
bigIntToVmNumber,
binToHex,
createCompilerBch,
createVirtualMachineBch,
encodeDataPush,
generatePrivateKey,
hash256,
secp256k1,
utf8ToBin,
} from '@bitauth/libauth';
import { XOTemplateBaseTypes, XOTemplatePrimitiveTypes } from '@xo-cash/types';
import {
isCashAssemblyExpression,
extractCashAssemblyEvaluations,
extractVariablesFromEvaluations,
decodeCompiledCashAssemblyEvaluation,
compileCashAssemblyString,
generateCashAssemblyBytecode,
compileCashAssemblyEvaluations,
resolvePrimitiveMethodBytes,
} from '../source/cash-assembly/index.ts';
import {
CashAssemblyRequiredVariableMissingError,
CashAssemblyCompilationFailedError,
CashAssemblyVariableTypeMismatchError,
CashAssemblyPrimitiveMethodMissingError,
CashAssemblyPrimitiveVariableMissingError,
CashAssemblyVmNumberDecodeError,
CashAssemblyNumberNotSafeIntegerError,
} from '../source/cash-assembly/errors.ts';
/**
* Tests that isCashAssemblyExpression recognizes a string made up entirely of one evaluation.
*/
const testIsCashAssemblyExpressionMatchesFullExpression = (): void => {
expect(isCashAssemblyExpression('$(<requestedSatoshis>)')).toBe(true);
};
/**
* Tests that isCashAssemblyExpression rejects a string that contains an evaluation plus other text.
*/
const testIsCashAssemblyExpressionRejectsSurroundingText = (): void => {
expect(isCashAssemblyExpression('Received $(<requestedSatoshis>)')).toBe(false);
};
/**
* Tests that isCashAssemblyExpression rejects plain text with no evaluation at all.
*/
const testIsCashAssemblyExpressionRejectsPlainText = (): void => {
expect(isCashAssemblyExpression('Received funds')).toBe(false);
};
/**
* Tests that isCashAssemblyExpression rejects an empty evaluation, since it references no variables.
*/
const testIsCashAssemblyExpressionRejectsEmptyEvaluation = (): void => {
expect(isCashAssemblyExpression('$()')).toBe(false);
};
/**
* Tests that isCashAssemblyExpression rejects non-string input rather than coercing it.
*/
const testIsCashAssemblyExpressionRejectsNonStringInput = (): void => {
// A number can never be a CashAssembly expression, regardless of its value
expect(isCashAssemblyExpression(5000)).toBe(false);
};
/**
* Tests that extractCashAssemblyEvaluations finds a single evaluation embedded in a larger string.
*/
const testExtractCashAssemblyEvaluationsFindsSingleEvaluation = (): void => {
const satoshisDescription = 'Received $(<requestedSatoshis>) satoshis from sender.';
// Only the evaluation substring is returned, not the surrounding text
expect(extractCashAssemblyEvaluations(satoshisDescription)).toStrictEqual([ '$(<requestedSatoshis>)' ]);
};
/**
* Tests that extractCashAssemblyEvaluations returns an empty array when the text has no evaluations.
*/
const testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound = (): void => {
expect(extractCashAssemblyEvaluations('Received funds')).toStrictEqual([]);
};
/**
* Tests that extractVariablesFromEvaluations returns each referenced variable name once,
* even when it appears in more than one evaluation.
*/
const testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations = (): void => {
const evaluations = [ '$(<requestedTokenAmount> <decimalsFactor> OP_DIV)', '$(<requestedTokenAmount> <decimalsFactor> OP_MOD)' ];
// requestedTokenAmount and decimalsFactor each appear in both evaluations but are listed once
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'requestedTokenAmount', 'decimalsFactor' ]);
};
/**
* Tests that extractVariablesFromEvaluations excludes numeric and quoted-string literal tokens,
* returning only the true variable reference.
*/
const testExtractVariablesFromEvaluationsExcludesLiteralTokens = (): void => {
const evaluation = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
// <0x02>, <"minting">, and <"immutable"> are literals, not variables, so only tokenCapability is returned
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'tokenCapability' ]);
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation converts VM number bytes to their decimal string
* when decode mode is bigint.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesBigint = (): void => {
const compiledResult = bigIntToVmNumber(1234n);
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'bigint')).toBe('1234');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation throws when bigint mode receives bytes that are not a VM number.
*/
const testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber = (): void => {
const decodeNonMinimalZero = (): string => decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 0x00 ]), 'bigint');
const expectedMessage =
'CashAssembly evaluation could not be decoded as a VM number: Failed to decode VM Number: the number is not minimally-encoded.';
expect(decodeNonMinimalZero).toThrow(CashAssemblyVmNumberDecodeError);
expect(decodeNonMinimalZero).toThrow(expectedMessage);
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation converts bytes to a hex string when decode mode is hex.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesHex = (): void => {
const compiledResult = new Uint8Array([ 0xab, 0xcd ]);
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'hex')).toBe('abcd');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation reports an empty byte array as 'false' and any
* non-empty byte array as 'true' when decode mode is boolean.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesBoolean = (): void => {
// An empty byte array is falsy on the BCH VM
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array(0), 'boolean')).toBe('false');
// Any non-empty byte array is truthy on the BCH VM
expect(decodeCompiledCashAssemblyEvaluation(new Uint8Array([ 1 ]), 'boolean')).toBe('true');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation defaults to utf8 decoding when no mode is given.
*/
const testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8 = (): void => {
const compiledResult = utf8ToBin('hello');
expect(decodeCompiledCashAssemblyEvaluation(compiledResult)).toBe('hello');
};
/**
* Tests that decodeCompiledCashAssemblyEvaluation returns the comma-separated decimal byte values
* when decode mode is uint8array.
*/
const testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array = (): void => {
const compiledResult = new Uint8Array([ 1, 2, 255 ]);
expect(decodeCompiledCashAssemblyEvaluation(compiledResult, 'uint8array')).toBe('1,2,255');
};
/**
* Tests that compileCashAssemblyString compiles a single evaluation and decodes it as a bigint.
*/
const testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint = (): void => {
const compiledSatoshisText = compileCashAssemblyString({
cashAssemblyText: '$(<requestedSatoshis>)',
variables: { requestedSatoshis: 5000n },
evaluationDecodeMode: 'bigint',
});
expect(compiledSatoshisText).toBe('5000');
};
/**
* Tests that compileCashAssemblyString compiles multiple evaluations embedded in the same string.
*
*/
const testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString = (): void => {
const tokenDescription =
'Transferred $(<requestedTokenAmount> <decimalsFactor> OP_DIV).$(<requestedTokenAmount> <decimalsFactor> OP_MOD) tokens.';
expect(extractCashAssemblyEvaluations(tokenDescription)).toStrictEqual([
'$(<requestedTokenAmount> <decimalsFactor> OP_DIV)',
'$(<requestedTokenAmount> <decimalsFactor> OP_MOD)',
]);
const compiledTokenText = compileCashAssemblyString({
cashAssemblyText: tokenDescription,
variables: { requestedTokenAmount: 1050n, decimalsFactor: 100n },
evaluationDecodeMode: 'bigint',
});
expect(compiledTokenText).toBe('Transferred 10.50 tokens.');
};
/**
* Tests that a variable referenced twice in one evaluation uses the same supplied value both times.
*/
const testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation = (): void => {
const evaluation = '$(<amount> <amount> OP_ADD)';
// The name appears twice in the script body but is required only once in the variables map.
expect(extractVariablesFromEvaluations([ evaluation ])).toStrictEqual([ 'amount' ]);
const compiledText = compileCashAssemblyString({
cashAssemblyText: evaluation,
variables: { amount: 21n },
evaluationDecodeMode: 'bigint',
});
expect(compiledText).toBe('42');
};
/**
* Tests that compileCashAssemblyString respects OP_IF/OP_ELSE branching driven by a variable.
*/
const testCompileCashAssemblyStringEvaluatesConditionalLiterals = (): void => {
const conditionalExpression = '$(<tokenCapability> <0x02> OP_EQUAL OP_IF <"minting"> OP_ELSE <"immutable"> OP_ENDIF)';
// tokenCapability equal to 2 takes the OP_IF branch
const mintingResult = compileCashAssemblyString({
cashAssemblyText: conditionalExpression,
variables: { tokenCapability: 2n },
evaluationDecodeMode: 'utf8',
});
expect(mintingResult).toBe('minting');
// tokenCapability not equal to 2 takes the OP_ELSE branch
const immutableResult = compileCashAssemblyString({
cashAssemblyText: conditionalExpression,
variables: { tokenCapability: 0n },
evaluationDecodeMode: 'utf8',
});
expect(immutableResult).toBe('immutable');
};
/**
* Tests that compileCashAssemblyString defaults to utf8 decoding when evaluationDecodeMode is omitted.
*/
const testCompileCashAssemblyStringDefaultsToUtf8DecodeMode = (): void => {
const compiledLabel = compileCashAssemblyString({
cashAssemblyText: '$(<label>)',
variables: { label: 'hello' },
});
expect(compiledLabel).toBe('hello');
};
/**
* Tests that compileCashAssemblyString accepts a Uint8Array variable value directly,
* and decodes the result as hex.
*/
const testCompileCashAssemblyStringAcceptsUint8ArrayVariable = (): void => {
const compiledHash = compileCashAssemblyString({
cashAssemblyText: '$(<hashBytes>)',
variables: { hashBytes: new Uint8Array([ 0xde, 0xad, 0xbe, 0xef ]) },
evaluationDecodeMode: 'hex',
});
expect(compiledHash).toBe('deadbeef');
};
/**
* Tests that compileCashAssemblyString throws CashAssemblyRequiredVariableMissingError, naming the missing
* variable, when the variables map does not contain a variable referenced by the text.
*/
const testCompileCashAssemblyStringThrowsForMissingVariable = (): void => {
const compileWithMissingVariable = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<requestedSatoshis>)',
variables: {},
evaluationDecodeMode: 'bigint',
});
expect(compileWithMissingVariable).toThrow(CashAssemblyRequiredVariableMissingError);
// The message states the reason (missing from the variables map) and names the specific variable
expect(compileWithMissingVariable).toThrow('Missing required variable: variableNames [requestedSatoshis]');
};
/**
* Tests that compileCashAssemblyString throws for a non integer number, and that the same display text
* can be produced with integer DIV and MOD instead of a float variable.
*/
const testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod = (): void => {
const compileWithNonInteger = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: 12.5 },
evaluationDecodeMode: 'utf8',
});
const expectedMessage = 'CashAssembly number is not a safe integer: identifier "amount", got 12.5';
expect(compileWithNonInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithNonInteger).toThrow(expectedMessage);
// Floats are rejected. Format decimals from integer quantity and scale with OP_DIV and OP_MOD.
const compiledText = compileCashAssemblyString({
cashAssemblyText: '$(<amount> <base> OP_DIV).$(<amount> <base> OP_MOD)',
variables: { amount: 125n, base: 10n },
evaluationDecodeMode: 'bigint',
});
expect(compiledText).toBe('12.5');
};
/**
* Tests that compileCashAssemblyString throws when a number variable is outside the safe integer range.
*/
const testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber = (): void => {
const unsafeInteger = Number.MAX_SAFE_INTEGER + 2;
const compileWithUnsafeInteger = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: unsafeInteger },
evaluationDecodeMode: 'utf8',
});
const expectedMessage = `CashAssembly number is not a safe integer: identifier "amount", got ${String(unsafeInteger)}`;
expect(compileWithUnsafeInteger).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithUnsafeInteger).toThrow(expectedMessage);
};
/**
* Tests that compileCashAssemblyString throws for non finite number values.
*/
const testCompileCashAssemblyStringThrowsForNonFiniteNumber = (): void => {
const compileWithNaN = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: Number.NaN },
evaluationDecodeMode: 'utf8',
});
const compileWithInfinity = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<amount>)',
variables: { amount: Number.POSITIVE_INFINITY },
evaluationDecodeMode: 'utf8',
});
expect(compileWithNaN).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithNaN).toThrow('CashAssembly number is not a safe integer: identifier "amount", got NaN');
expect(compileWithInfinity).toThrow(CashAssemblyNumberNotSafeIntegerError);
expect(compileWithInfinity).toThrow('CashAssembly number is not a safe integer: identifier "amount", got Infinity');
};
/**
* Tests that a decoded variable value which happens to contain text shaped like a CashAssembly
* evaluation (e.g. "$(<real>)") is left alone as plain output text
*/
const testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText = (): void => {
const compiledText = compileCashAssemblyString({
cashAssemblyText: '$(<label>)$(<real>)',
variables: { label: '$(<real>)', real: '999' },
evaluationDecodeMode: 'utf8',
});
expect(compiledText).toBe('$(<real>)999');
};
/**
* Tests that omitting templateVariables leaves method shaped evaluations for CashAssembly.
*/
const testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables = (): void => {
const compileWithoutTemplateVariables = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<expiry.toIso8601>)',
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
});
expect(compileWithoutTemplateVariables).toThrow(CashAssemblyRequiredVariableMissingError);
expect(compileWithoutTemplateVariables).toThrow('Missing required variable: variableNames [expiry.toIso8601]');
};
/**
* Tests that an unsupported or missing hint leaves the evaluation for CashAssembly instead of throwing.
*/
const testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive = (): void => {
const compileWithNonPrimitiveHint = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<label.toIso8601>)',
variables: { label: 'hello' },
templateVariables: {
label: {
name: 'Label',
description: 'A text label',
type: XOTemplateBaseTypes.STRING,
},
},
});
expect(compileWithNonPrimitiveHint).toThrow(CashAssemblyRequiredVariableMissingError);
expect(compileWithNonPrimitiveHint).toThrow('Missing required variable: variableNames [label.toIso8601]');
};
/**
* Tests that a supported hint with an unknown method throws CashAssemblyPrimitiveMethodMissingError.
*
* Once the hint maps to a supported primitive class, a missing method is a primitive resolution
* failure rather than a CashAssembly missing variable.
*/
const testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive = (): void => {
const compileWithUnknownMethod = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<expiry.notARealMethod>)',
variables: { expiry: Date.parse('2024-01-15T10:30:00.000Z') },
templateVariables: {
expiry: {
name: 'Expiry',
description: 'Invitation expiry time',
type: XOTemplateBaseTypes.INTEGER,
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
},
},
});
expect(compileWithUnknownMethod).toThrow(CashAssemblyPrimitiveMethodMissingError);
expect(compileWithUnknownMethod).toThrow('CashAssembly primitive method does not exist: identifier "expiry.notARealMethod", methodName "notARealMethod", hint "timestamp"');
};
/**
* Tests that a committed supported transform throws when the runtime value is missing.
*/
const testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue = (): void => {
const compileWithMissingValue = (): string =>
compileCashAssemblyString({
cashAssemblyText: '$(<expiry.toIso8601>)',
variables: {},
templateVariables: {
expiry: {
name: 'Expiry',
description: 'Invitation expiry time',
type: XOTemplateBaseTypes.INTEGER,
hint: XOTemplatePrimitiveTypes.TIMESTAMP,
},
},
});
expect(compileWithMissingValue).toThrow(CashAssemblyPrimitiveVariableMissingError);
expect(compileWithMissingValue).toThrow('CashAssembly primitive variable is missing from the variables map: identifier "expiry.toIso8601", variableName "expiry"');
};
/**
* Tests that multiple supported `<base.method>` pushes inside one evaluation.
*/
const testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation = (): void => {
const compiledText = compileCashAssemblyString({
cashAssemblyText: '$(<amount.toSatoshis> <fee.toSatoshis> OP_SUB)',
variables: {
amount: 5000n,
fee: 1000n,
},
templateVariables: {
amount: {
name: 'Amount',
description: 'Payment in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
fee: {
name: 'Fee',
description: 'Network fee in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
},
evaluationDecodeMode: 'bigint',
});
expect(compiledText).toBe('4000');
};
/**
* Tests that lock and display evaluations are extracted from one string and that the lock
* hash evaluation compiles to the HASH256 of the redeem script.
*/
const testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText = (): void => {
// Test only key material. Never use with real funds.
const privateKeyBytes = generatePrivateKey();
const publicKeyResult = secp256k1.derivePublicKeyCompressed(privateKeyBytes);
expect(typeof publicKeyResult).not.toBe('string');
const publicKeyBytes = publicKeyResult as Uint8Array;
// Minimal P2S redeem body. Push the compressed public key, then OP_CHECKSIG.
const redeemScript = Uint8Array.from([ ...encodeDataPush(publicKeyBytes), 0xac ]);
const expectedRedeemHashHex = binToHex(hash256(redeemScript));
const lockText = 'Lock OP_HASH256 <$(<redeemScript> OP_HASH256)> OP_EQUAL. ';
const displayTextSource = 'Expires $(<expiry.toIso8601>). Amount $(<amount.toBCH>) BCH ($(<amount.toString>) satoshis).';
const cashAssemblyText = `${lockText}${displayTextSource}`;
const evaluations = extractCashAssemblyEvaluations(cashAssemblyText);
expect(evaluations).toStrictEqual([ '$(<redeemScript> OP_HASH256)', '$(<expiry.toIso8601>)', '$(<amount.toBCH>)', '$(<amount.toString>)' ]);
expect(extractVariablesFromEvaluations(evaluations)).toStrictEqual([ 'redeemScript', 'expiry.toIso8601', 'amount.toBCH', 'amount.toString' ]);
const redeemHashHex = compileCashAssemblyString({
cashAssemblyText: evaluations[0],
variables: { redeemScript },
evaluationDecodeMode: 'hex',
});
expect(redeemHashHex).toBe(expectedRedeemHashHex);
};
/**
* Tests a P2SH32 spend whose redeem script mixes primitive method evaluations with a real Schnorr checksig.
*
* Redeem requires:
* 1. A P2PKH style signature check against owner.public_key.
* 2. UTXO value equal to price + fee, where price and fee are bound via Satoshis.toSatoshis then OP_ADD.
*/
const testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully = (): void => {
// Test only key material. Never use with real funds.
const privateKeyBytes = generatePrivateKey();
const priceSatoshis = 5000n;
const feeSatoshis = 1000n;
const totalSatoshis = 6000n;
const redeemScriptSource =
'OP_DUP OP_HASH160 <$(<owner.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIGVERIFY '
+ 'OP_INPUTINDEX OP_UTXOVALUE <$(<price.toSatoshis> <fee.toSatoshis> OP_ADD)> OP_EQUAL';
// Resolve primitive method pushes so CashAssembly receives VM number bytes under the full identifiers.
const primitiveMethodBytes = resolvePrimitiveMethodBytes({
identifiers: [ 'price.toSatoshis', 'fee.toSatoshis' ],
variables: {
price: priceSatoshis,
fee: feeSatoshis,
},
templateVariables: {
price: {
name: 'Price',
description: 'Base payment in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
fee: {
name: 'Fee',
description: 'Network fee in satoshis',
type: XOTemplateBaseTypes.BIGINT,
hint: XOTemplatePrimitiveTypes.SATOSHIS,
},
},
});
expect(Object.keys(primitiveMethodBytes)).toStrictEqual([ 'price.toSatoshis', 'fee.toSatoshis' ]);
const redeemCompiler = createCompilerBch({
scripts: {
redeem: redeemScriptSource,
},
variables: {
owner: { type: 'Key' },
price: { type: 'WalletData' },
fee: { type: 'WalletData' },
},
});
const redeemResult = redeemCompiler.generateBytecode({
data: {
keys: { privateKeys: { owner: privateKeyBytes } },
bytecode: primitiveMethodBytes,
},
scriptId: 'redeem',
});
expect(redeemResult.success).toBe(true);
if (redeemResult.success !== true) {
return;
}
const redeemBytecode = redeemResult.bytecode;
// P2SH32 lock is OP_HASH256 <hash256(redeem)> OP_EQUAL.
const lockingBytecode = Uint8Array.from([ 0xaa, ...encodeDataPush(hash256(redeemBytecode)), 0x87 ]);
// Unlock compiler signs against the same redeem body used as the covered bytecode for P2SH.
const unlockCompiler = createCompilerBch({
scripts: {
lock: redeemScriptSource,
unlock: '<owner.schnorr_signature.all_outputs> <owner.public_key>',
},
unlockingScripts: {
unlock: 'lock',
},
variables: {
owner: { type: 'Key' },
price: { type: 'WalletData' },
fee: { type: 'WalletData' },
},
});
const program = {
inputIndex: 0,
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis }],
transaction: {
inputs: [
{
outpointIndex: 0,
outpointTransactionHash: new Uint8Array(32),
sequenceNumber: 0,
unlockingBytecode: new Uint8Array(),
},
],
locktime: 0,
outputs: [{ lockingBytecode: new Uint8Array(), valueSatoshis: totalSatoshis }],
version: 2,
},
};
const unlockResult = unlockCompiler.generateBytecode({
data: {
keys: { privateKeys: { owner: privateKeyBytes } },
bytecode: primitiveMethodBytes,
compilationContext: program,
},
scriptId: 'unlock',
});
expect(unlockResult.success).toBe(true);
if (unlockResult.success !== true) {
return;
}
// P2SH unlock pushes signature and public key, then the redeem script itself.
program.transaction.inputs[0].unlockingBytecode = Uint8Array.from([ ...unlockResult.bytecode, ...encodeDataPush(redeemBytecode) ]);
const virtualMachine = createVirtualMachineBch();
const evaluationResult = virtualMachine.evaluate(program);
expect(virtualMachine.stateSuccess(evaluationResult)).toBe(true);
// Wrong UTXO value must fail the baked price + fee equality check.
const wrongValueProgram = {
...program,
sourceOutputs: [{ lockingBytecode, valueSatoshis: totalSatoshis - 1n }],
transaction: {
...program.transaction,
inputs: [
{
...program.transaction.inputs[0],
unlockingBytecode: new Uint8Array(),
},
],
},
};
const wrongValueUnlockResult = unlockCompiler.generateBytecode({
data: {
keys: { privateKeys: { owner: privateKeyBytes } },
bytecode: primitiveMethodBytes,
compilationContext: wrongValueProgram,
},
scriptId: 'unlock',
});
expect(wrongValueUnlockResult.success).toBe(true);
if (wrongValueUnlockResult.success !== true) {
return;
}
wrongValueProgram.transaction.inputs[0].unlockingBytecode = Uint8Array.from([
...wrongValueUnlockResult.bytecode,
...encodeDataPush(redeemBytecode),
]);
expect(virtualMachine.stateSuccess(virtualMachine.evaluate(wrongValueProgram))).not.toBe(true);
};
/**
* Tests that generateCashAssemblyBytecode produces the pushed bytes for a variable evaluation.
*/
const testGenerateCashAssemblyBytecodeProducesRawBytesForVariable = (): void => {
const evaluation = '$(<inputValue>)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const bytecode = generateCashAssemblyBytecode(compiler, evaluation, { inputValue: new Uint8Array([ 4 ]) });
expect(bytecode).toStrictEqual(new Uint8Array([ 4 ]));
};
/**
* Tests that generateCashAssemblyBytecode throws CashAssemblyVariableTypeMismatchError when a variable value is not a Uint8Array.
*/
const testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable = (): void => {
const evaluation = '$(<inputValue>)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const generateWithTypeMismatch = (): Uint8Array =>
generateCashAssemblyBytecode(
compiler,
evaluation,
// @ts-expect-error intentional non Uint8Array input to exercise the runtime guard
{ inputValue: 4 },
);
expect(generateWithTypeMismatch).toThrow(CashAssemblyVariableTypeMismatchError);
expect(generateWithTypeMismatch).toThrow('Variable type mismatch: variableKey "inputValue", expected Uint8Array, got number');
};
/**
* Tests that generateCashAssemblyBytecode throws CashAssemblyCompilationFailedError when the
* evaluation references an identifier the compiler cannot resolve as an opcode, variable, or script.
*/
const testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier = (): void => {
const evaluation = '$(<inputValue> unresolvedIdentifier)';
const compiler = compileCashAssemblyEvaluations([ evaluation ]);
const variables = { inputValue: new Uint8Array([ 1 ]) };
const generateWithUnresolvedIdentifier = (): Uint8Array => generateCashAssemblyBytecode(compiler, evaluation, variables);
expect(generateWithUnresolvedIdentifier).toThrow(CashAssemblyCompilationFailedError);
expect(generateWithUnresolvedIdentifier).toThrow('Cash assembly compilation failed: Unknown identifier "unresolvedIdentifier".');
};
const runTests = async (): Promise<void> => {
test('isCashAssemblyExpression: matches a full expression', testIsCashAssemblyExpressionMatchesFullExpression);
test('isCashAssemblyExpression: rejects surrounding text', testIsCashAssemblyExpressionRejectsSurroundingText);
test('isCashAssemblyExpression: rejects plain text', testIsCashAssemblyExpressionRejectsPlainText);
test('isCashAssemblyExpression: rejects an empty evaluation', testIsCashAssemblyExpressionRejectsEmptyEvaluation);
test('isCashAssemblyExpression: rejects non-string input', testIsCashAssemblyExpressionRejectsNonStringInput);
test('extractCashAssemblyEvaluations: finds a single evaluation', testExtractCashAssemblyEvaluationsFindsSingleEvaluation);
test(
'extractCashAssemblyEvaluations: returns an empty array when none are found',
testExtractCashAssemblyEvaluationsReturnsEmptyArrayWhenNoneFound,
);
test('extractVariablesFromEvaluations: deduplicates across evaluations', testExtractVariablesFromEvaluationsDeduplicatesAcrossEvaluations);
test('extractVariablesFromEvaluations: excludes literal tokens', testExtractVariablesFromEvaluationsExcludesLiteralTokens);
test('decodeCompiledCashAssemblyEvaluation: decodes bigint', testDecodeCompiledCashAssemblyEvaluationDecodesBigint);
test(
'decodeCompiledCashAssemblyEvaluation: throws when bigint bytes are not a VM number',
testDecodeCompiledCashAssemblyEvaluationThrowsWhenBigintIsNotAVmNumber,
);
test('decodeCompiledCashAssemblyEvaluation: decodes hex', testDecodeCompiledCashAssemblyEvaluationDecodesHex);
test('decodeCompiledCashAssemblyEvaluation: decodes boolean', testDecodeCompiledCashAssemblyEvaluationDecodesBoolean);
test('decodeCompiledCashAssemblyEvaluation: defaults to utf8', testDecodeCompiledCashAssemblyEvaluationDefaultsToUtf8);
test('decodeCompiledCashAssemblyEvaluation: decodes uint8array', testDecodeCompiledCashAssemblyEvaluationDecodesUint8Array);
test('compileCashAssemblyString: decodes a single evaluation as bigint', testCompileCashAssemblyStringDecodesSingleEvaluationAsBigint);
test(
'compileCashAssemblyString: compiles multiple evaluations in one string',
testCompileCashAssemblyStringCompilesMultipleEvaluationsInOneString,
);
test(
'compileCashAssemblyString: reuses the same variable twice in one evaluation',
testCompileCashAssemblyStringReusesTheSameVariableTwiceInOneEvaluation,
);
test('compileCashAssemblyString: evaluates conditional literals', testCompileCashAssemblyStringEvaluatesConditionalLiterals);
test('compileCashAssemblyString: defaults to utf8 decode mode', testCompileCashAssemblyStringDefaultsToUtf8DecodeMode);
test('compileCashAssemblyString: accepts a Uint8Array variable', testCompileCashAssemblyStringAcceptsUint8ArrayVariable);
test('compileCashAssemblyString: throws for a missing variable', testCompileCashAssemblyStringThrowsForMissingVariable);
test(
'compileCashAssemblyString: throws for a non-integer number and produces the same display with integer DIV and MOD',
testCompileCashAssemblyStringThrowsForNonIntegerNumberAndProducesSameDisplayWithIntegerDivMod,
);
test('compileCashAssemblyString: throws for an unsafe integer number', testCompileCashAssemblyStringThrowsForUnsafeIntegerNumber);
test('compileCashAssemblyString: throws for a non-finite number', testCompileCashAssemblyStringThrowsForNonFiniteNumber);
test(
'compileCashAssemblyString: does not reinterpret injected evaluation-looking text',
testCompileCashAssemblyStringDoesNotReinterpretInjectedEvaluationLookingText,
);
test(
'compileCashAssemblyString: leaves method evaluations for CashAssembly without templateVariables',
testCompileCashAssemblyStringLeavesMethodEvaluationForCashAssemblyWithoutTemplateVariables,
);
test(
'compileCashAssemblyString: skips when hint is not a supported primitive',
testCompileCashAssemblyStringSkipsWhenHintIsNotASupportedPrimitive,
);
test(
'compileCashAssemblyString: throws when method does not exist on the primitive',
testCompileCashAssemblyStringThrowsWhenMethodDoesNotExistOnPrimitive,
);
test(
'compileCashAssemblyString: throws when a supported transform is missing its runtime value',
testCompileCashAssemblyStringThrowsWhenSupportedTransformIsMissingRuntimeValue,
);
test(
'compileCashAssemblyString: resolves multiple primitive method pushes in one evaluation',
testCompileCashAssemblyStringResolvesMultiplePrimitiveMethodPushesInOneEvaluation,
);
test(
'compileCashAssemblyString: hex-compiles a P2S lock hash extracted from mixed lock and display text',
testCompileCashAssemblyStringHexCompilesP2sLockHashFromMixedLockAndDisplayText,
);
test(
'P2SH32 redeem: primitive OP_ADD and Schnorr signature spend successfully',
testP2sh32RedeemWithPrimitiveAddAndSchnorrSignatureSpendsSuccessfully,
);
test('generateCashAssemblyBytecode: produces raw bytes for a variable', testGenerateCashAssemblyBytecodeProducesRawBytesForVariable);
test('generateCashAssemblyBytecode: throws for a non-Uint8Array variable', testGenerateCashAssemblyBytecodeThrowsForNonUint8ArrayVariable);
test('generateCashAssemblyBytecode: throws for an unresolved identifier', testGenerateCashAssemblyBytecodeThrowsForUnresolvedIdentifier);
};
await runTests();
+31 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from 'vitest';
import { extendedJsonReviver } from '../source/index.ts';
import { extendedJsonReviver, toExtendedJson, fromExtendedJson } from '../source/index.ts';
/**
* Tests that extendedJsonReviver reconstructs a positive bigint.
@@ -64,6 +64,34 @@ const testReviverPassesThroughPlainString = (): void => {
expect(reconstructed).toBe('just a string');
};
/**
* Tests that toExtendedJson serializes an object to a string.
*/
const testToExtendedJsonSerializesObject = (): void => {
// Define an object with a Uint8Array
const extendedObject = { bytes: new Uint8Array([ 0xab, 0xcd ]) };
// Serialize the object to a string
const serialized = toExtendedJson(extendedObject);
// The serialized string should contain the Uint8Array in Extended JSON format (Uint8Arrays are encoded as hex strings)
expect(serialized).toBe('{"bytes":"<uint8array: abcd>"}');
};
/**
* Tests that fromExtendedJson deserializes a string to an object.
*/
const testFromExtendedJsonDeserializesString = (): void => {
// Define a string in Extended JSON format that contains a Uint8Array
const serializedObject = '{"bytes":"<uint8array: abcd>"}';
// Deserialize the string to an object
const deserialized = fromExtendedJson(serializedObject);
// The deserialized object should contain the Uint8Array
expect(deserialized).toStrictEqual({ bytes: new Uint8Array([ 0xab, 0xcd ]) });
};
/**
* Tests that extendedJsonReviver passes through non-string values.
*/
@@ -85,6 +113,8 @@ const runTests = async (): Promise<void> => {
test('extendedJsonReviver: reconstructs an empty Uint8Array', testReviverReconstructsEmptyUint8Array);
test('extendedJsonReviver: passes through plain strings', testReviverPassesThroughPlainString);
test('extendedJsonReviver: passes through non-string values', testReviverPassesThroughNonStringValues);
test('toExtendedJson: serializes an object to a string', testToExtendedJsonSerializesObject);
test('fromExtendedJson: deserializes a string to an object', testFromExtendedJsonDeserializesString);
};
await runTests();
@@ -0,0 +1,214 @@
import { expect, test, vi } from 'vitest';
import { AsyncPushIterator } from '../../source/sse-session/async-push-iterator.ts';
/**
* Collects every value from the iterator into an array.
*
* @param iterator - Iterator under test.
*/
const collectAll = async <T>(iterator: AsyncPushIterator<T>): Promise<T[]> => {
const results: T[] = [];
for await (const value of iterator) {
results.push(value);
}
return results;
};
/**
* Tests that values pushed while a consumer is already waiting are delivered in order.
*/
const testPushComposedPushAndConsume = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = (): Promise<number[]> => collectAll(iterator);
iterator.push(1);
iterator.push(2);
iterator.push(3);
iterator.close();
await expect(result()).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that values pushed before `for await...of` starts are buffered and yielded
* once the consumer begins reading.
*/
const testPushComposedBuffersValuesPushedBeforeLoopStarts = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.push(1);
iterator.push(2);
iterator.push(3);
const result = collectAll(iterator);
iterator.close();
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that the iterator completes with no values when nothing was pushed.
*/
const testPushComposedResolvesWithNoValues = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = async (): Promise<number[]> => collectAll(iterator);
iterator.close();
await expect(result()).resolves.toEqual([]);
};
/**
* Tests that values pushed after {@link AsyncPushIterator.close} are ignored.
*/
const testPushComposedIgnoresValuesAfterClose = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = async (): Promise<number[]> => collectAll(iterator);
iterator.push(1);
iterator.push(2);
iterator.push(3);
iterator.close();
iterator.push(4);
await expect(result()).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that only one async consumer can read from the composed ReadableStream at a time.
*
* Unlike the hand-rolled async-push-iterator, the second consumer fails with a
* stream lock error rather than TooManyAsyncIteratorsError.
*/
const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const failureFlag = vi.fn();
const successfulIterator = (): Promise<number[]> => collectAll(iterator);
const failedIterator = async (): Promise<void> => {
try {
/* eslint-disable-next-line */
for await (const _value of iterator) {
}
} catch {
failureFlag();
}
};
const promises = [ successfulIterator(), failedIterator() ];
iterator.close();
await Promise.all(promises);
expect(failureFlag).toHaveBeenCalledOnce();
};
/**
* Tests that closing before iteration starts lets the loop finish immediately.
*/
const testPushComposedResolvesWhenClosedBeforeLoop = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.close();
await expect(collectAll(iterator)).resolves.toEqual([]);
};
/**
* Tests that breaking out of `for await...of` early does not cancel the stream.
*
* {@link AsyncPushIterator} uses `preventCancel: true` so producers can keep pushing
* and a later consumer can read the remaining values.
*/
const testPushComposedAllowsPushingAfterEarlyBreak = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.push(1);
const firstPass: number[] = [];
for await (const value of iterator) {
firstPass.push(value);
break;
}
iterator.push(2);
iterator.push(3);
iterator.close();
const secondPass = await collectAll(iterator);
expect(firstPass).toEqual([ 1 ]);
expect(secondPass).toEqual([ 2, 3 ]);
};
/**
* Tests that the iterator rejects after {@link AsyncPushIterator.error} is called.
*/
const testPushIteratorRejectsAfterError = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.error(new Error('Stream has been closed for a test'));
await expect(collectAll(iterator)).rejects.toThrow('Stream has been closed for a test');
};
/**
* Tests that the iterator closes the stream when error() is called.
*/
const testPushIteratorClosesWhenErrorIsCalled = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.error(new Error('Stream has been closed for a test'));
expect(iterator.closed).toBe(true);
};
/**
* Tests that subsequent calls to error() are ignored.
*/
const testPushIteratorIgnoresSubsequentErrorCalls = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.error(new Error('Stream has been closed for a test'));
expect(iterator.closed).toBe(true);
expect(iterator.error(new Error('Second error'))).toBe(undefined);
};
/**
* Tests that a consumer can check if the iterator is closed.
*/
const testPushIteratorCanCheckIfClosed = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
expect(iterator.closed).toBe(false);
iterator.close();
expect(iterator.closed).toBe(true);
};
const runTests = async (): Promise<void> => {
test('AsyncPushIterator: pushes and consumes values', testPushComposedPushAndConsume);
test('AsyncPushIterator: buffers values pushed before the for-await loop starts', testPushComposedBuffersValuesPushedBeforeLoopStarts);
test('AsyncPushIterator: resolves with no values when nothing was pushed', testPushComposedResolvesWithNoValues);
test('AsyncPushIterator: ignores values pushed after close', testPushComposedIgnoresValuesAfterClose);
test('AsyncPushIterator: rejects multiple consumers', testPushComposedRejectsMultipleConsumers);
test('AsyncPushIterator: resolves immediately when closed before the loop starts', testPushComposedResolvesWhenClosedBeforeLoop);
test('AsyncPushIterator: keeps the stream open after an early break', testPushComposedAllowsPushingAfterEarlyBreak);
test('AsyncPushIterator: rejects after error', testPushIteratorRejectsAfterError);
test('AsyncPushIterator: closes the stream when error() is called', testPushIteratorClosesWhenErrorIsCalled);
test('AsyncPushIterator: ignores subsequent error() calls', testPushIteratorIgnoresSubsequentErrorCalls);
test('AsyncPushIterator: can check if closed', testPushIteratorCanCheckIfClosed);
};
await runTests();
@@ -0,0 +1,155 @@
import type { SSEvent } from '../../../source/sse-session/types.ts';
type EventFixture = {
raw: string;
parsed?: SSEvent[];
};
/** Combines all the raw strings into a single chunk and flattens the parsed arrays into a single array to simulate multi-event chunks. */
const withCombinedChunk = (fixtures: EventFixture[]): EventFixture => {
return {
raw: fixtures.map(({ raw }) => raw).join(''),
parsed: fixtures.flatMap(({ parsed }) => parsed ?? []),
};
};
export const priceOracleEvents: EventFixture[] = [
{
raw: 'retry: 1000\nevent: 02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3\ndata: {"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}\n\n',
parsed: [
{
retry: 1000,
event: '02664276fb7513f838f505c221680a9d963479ffb45452b0c744ddb6bd19ecacb3',
data: '{"message":"411d396aa53516008f35160049a46100","signature":"936ee3de4c179a1c23d5227c6cadd7ccee11aa1a5a48624ff59ff6954dc607752be8d069610a342ed7610444741bc5fa5dd9bd438fa6c1f1307a7b50a663e17c"}',
},
],
},
{
raw: 'event: 0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830\ndata: {"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}\n\n',
parsed: [
{
event: '0336f13d65e3bd6a521bf582f22b74f50edab7c278d38b80e319673b859f95d830',
data: '{"message":"471d396ac4571200b057120047e80000","signature":"4fb94f8a46475ebac87d49860372c67dae9e89412ff704b14e5248c4911228ad4c6429ae3771bf33122ebbfac3084c6a13efdbff9cb987af55032364ad6e1816"}',
},
],
},
{
raw: 'event: 021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd\ndata: {"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}\n\n',
parsed: [
{
event: '021f8338ccd45a7790025de198a266f252ac43c95bf81d2469feff110beeac89dd',
data: '{"message":"491d396aad341a0092341a0064120000","signature":"dde76381753edd39beaeef0a413da54e906c9a2e2bb2ae482c14e72702cdff43e1ad26354663c15236ad9ec04e054ef39b4047c24c859d4f5aef1f58502b4686"}',
},
],
},
{
raw: 'event: 02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1\ndata: {"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}\n\n',
parsed: [
{
event: '02e82ad82eb88fcdfd02fd5e2e0a67bc6ef4139bbcb63ce0b107a7604deb9f7ce1',
data: '{"message":"4b1d396ab5341a009a341a00a0490000","signature":"3bbd83943e3cad352c3346fe6fa68913f66529551dab5aef574b73b5c276f9d860011dfb2d7ecae7f12b92b53b1dac4b94d15a3d78adcc70133bfef4c45e13c9"}',
},
],
},
{
raw: 'event: 038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d\ndata: {"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}\n\n',
parsed: [
{
event: '038ab22e37cf020f6bbef40111ddc51083a936f0821de56ac01f799cf15b87904d',
data: '{"message":"4f1d396abd341a00a2341a00722c0000","signature":"3b771dc4490010066ccca9e02aa467b950d6fb5cd0000e3f59ec4fadffc5aedb6369b7a62b9c08695d6fa114cfbd62f3e98286ac98e276f66e481a89764a3716"}',
},
],
},
{
raw: 'event: 030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a\ndata: {"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}\n\n',
parsed: [
{
event: '030654b9598186fe4bc9e1b0490c6b85b13991cdb9a7afa34af1bbeee22a35487a',
data: '{"message":"531d396abc341a00a1341a001f0f0200","signature":"380e5954336b855736e22e602b543c4cb2918f0c0bb67d8723573f9cabb4dcf14714ebbfffb7687f97cad21115659d28d493b4e83014a7b62e36dd277d1c44c3"}',
},
],
},
{
raw: 'event: 03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c\ndata: {"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}\n\n',
parsed: [
{
event: '03e980928f14fc98e1f9d75d15f0b67dc58cdd3f5c641b8f825b146bcc04bd232c',
data: '{"message":"531d396aa6952100e791210064120000","signature":"fc9acd0b9a0f09e1a5f48d6b61b27a445529cf36b88b39d5df5ed83edbc6b4469ede6933f001f9202f2b590dd8e0f016b9175157fbc5d6189a5fc78cc0443e4d"}',
},
],
},
{
raw: 'event: 02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5\ndata: {"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}\n\n',
parsed: [
{
event: '02bb9b3324df889a66a57bc890b3452b84a2a74ba753f8842b06bba03e0fa0dfc5',
data: '{"message":"541d396adc191800c419180060440000","signature":"ed1e37324b58815cf448d16b653654c9759817461460fb929893b8afb763ea2aa8ad566149cc2e429590308f4c17f4c1cc74ba0384dff01fc039f941238d8590"}',
},
],
},
{
raw: 'event: 02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047\ndata: {"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}\n\n',
parsed: [
{
event: '02d3c1de9d4bc77d6c3608cbe44d10138c7488e592dc2b1e10a6cf0e92c2ecb047',
data: '{"message":"551d396a17952100d2912100474e0000","signature":"74d737547c4ee207d1bcf43eba0ae3c73264f1f477a2947ecf8685cdf0c79a408b22e8df49fe487c34ba0efe8cc520745d45b6338d69c493db36bdbf511b72fb"}',
},
],
},
];
export const storageEvents: EventFixture[] = [
{
raw: 'id: 1234\ndata: { "hello": "world" }\n\n',
parsed: [
{
id: '1234',
data: '{ "hello": "world" }',
},
],
},
];
export const edgeCases: EventFixture[] = [
// Multiple data lines
{
raw: 'data: { "hello": "world" }\ndata: { "hello": "world" }\n\n',
parsed: [
{
data: '{ "hello": "world" }\n{ "hello": "world" }',
},
],
},
// Message without any colons
{
raw: 'message without any colons\n\n',
parsed: [],
},
// Retry without a number
{
raw: 'retry: not a number\n\n',
parsed: [],
},
// Data that contains a string with a new line in it
{
raw: 'data: { "hello": "world\\n" }\n\n',
parsed: [
{
data: '{ "hello": "world\\n" }',
},
],
},
// Emoji character support (mostly to test partial chunks)
{
raw: 'data: Hello 😀 world\n\n',
parsed: [
{
data: 'Hello 😀 world',
},
],
},
];
export const priceOracleEventsCombined = withCombinedChunk(priceOracleEvents);
export const storageEventsCombined = withCombinedChunk(storageEvents);
export const edgeCasesCombined = withCombinedChunk(edgeCases);
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from 'vitest';
import { SSEEventParser } from '../../source/sse-session/sse-event-parser.ts';
import type { SSEvent } from '../../source/sse-session/types.ts';
import {
edgeCases,
priceOracleEvents,
storageEvents,
priceOracleEventsCombined,
storageEventsCombined,
edgeCasesCombined,
} from './fixtures/events.fixtures.ts';
/** Shared encoder for turning fixture strings into stream bytes. */
const textEncoder = new TextEncoder();
/**
* Tests that SSEEventParser parses a simple data event.
*/
const testSseEventParserParsesSimpleEvent = (): void => {
const parser = new SSEEventParser();
const events = parser.parseEvents(textEncoder.encode('data: test\n\n'));
expect(events).toEqual([{ data: 'test' }]);
};
/**
* Tests that SSEEventParser parses all fixture events correctly.
*/
const testSseEventParserParsesAllFixtures = (): void => {
const parser = new SSEEventParser();
// Combine all individual event fixtures from each domain.
const combinedEvents = [ ...priceOracleEvents, ...storageEvents, ...edgeCases ];
// Iterate over each combined fixture and test that the parser parses all events correctly.
for (const { raw, parsed } of combinedEvents) {
const events = parser.parseEvents(textEncoder.encode(raw));
expect(events).toEqual(parsed);
}
};
/**
* Tests that SSEEventParser handles multiple events in the same chunk.
*/
const testSseEventParserHandlesMultipleEventsInOneChunk = (): void => {
const parser = new SSEEventParser();
// Each combined fixture packs several events into one raw payload.
const allEvents = [ priceOracleEventsCombined, storageEventsCombined, edgeCasesCombined ];
// Iterate over each combined fixture and test that the parser handles the multiple events in one chunk correctly.
for (const { raw, parsed } of allEvents) {
const bytes = textEncoder.encode(raw);
const events = parser.parseEvents(bytes);
expect(events).toEqual(parsed);
}
};
/**
* Tests that SSEEventParser handles partial chunks delivered one character at a time.
*/
const testSseEventParserHandlesPartialChunks = (): void => {
const fixtures = [ ...priceOracleEvents, ...storageEvents, ...edgeCases ];
// Iterate over each fixture and test that the parser handles the partial chunks correctly.
for (const { raw, parsed } of fixtures) {
const parser = new SSEEventParser();
const finalEvents: SSEvent[] = [];
// Iterate over each character in the raw string and try to parse the events in its buffer
for (const character of raw) {
const bytes = textEncoder.encode(character);
const events = parser.parseEvents(bytes);
finalEvents.push(...events);
}
// Verify the events match the expected events.
expect(finalEvents).toEqual(parsed ?? []);
}
};
/**
* Tests that SSEEventParser handles partial byte chunks delivered one byte at a time.
* This tests that unicode characters (like emojis) are still parsed correctly despite being split between two "chunks".
*/
const testSseEventParserHandlesPartialByteChunks = (): void => {
// Combine all individual event fixtures from each domain.
const fixtures = [ ...priceOracleEvents, ...storageEvents, ...edgeCases ];
// Iterate over each fixture and test that the parser handles the partial byte chunks correctly.
for (const { raw, parsed } of fixtures) {
const parser = new SSEEventParser();
const finalEvents: SSEvent[] = [];
const bytes = textEncoder.encode(raw);
// Iterate over each byte in the raw string and try to parse the events in its buffer
for (const byte of bytes) {
const uint8Bytes = Uint8Array.of(byte);
const events = parser.parseEvents(uint8Bytes);
finalEvents.push(...events);
}
// Verify the events match the expected events.
expect(finalEvents).toEqual(parsed ?? []);
}
};
/**
* Tests that SSEEventParser clears its buffer when reset is called.
*/
const testSseEventParserClearsBufferOnReset = (): void => {
// Create a new parser.
const parser = new SSEEventParser();
// Parse the events in the buffer.
parser.parseEvents(textEncoder.encode('data: stale'));
// Reset the parser.
parser.reset();
// Parse the events in the buffer.
const events = parser.parseEvents(textEncoder.encode('data: fresh\n\n'));
// Verify the events match the expected events.
expect(events).toEqual([
{
data: 'fresh',
},
]);
};
const runTests = async (): Promise<void> => {
test('SSEEventParser: parses a simple data event', testSseEventParserParsesSimpleEvent);
test('SSEEventParser: parses all fixture events', testSseEventParserParsesAllFixtures);
test('SSEEventParser: handles multiple events in one chunk', testSseEventParserHandlesMultipleEventsInOneChunk);
test('SSEEventParser: handles partial chunks', testSseEventParserHandlesPartialChunks);
test('SSEEventParser: handles partial byte chunks', testSseEventParserHandlesPartialByteChunks);
test('SSEEventParser: clears the buffer on reset', testSseEventParserClearsBufferOnReset);
};
await runTests();