@hirosystems/clarinet-sdk
Version:
A SDK to interact with Clarity Smart Contracts in node.js
390 lines (331 loc) • 12.3 kB
text/typescript
import { expect, ExpectStatic, assert } from "vitest";
import {
Cl,
ClarityValue,
ClarityType,
ResponseOkCV,
NoneCV,
SomeCV,
ResponseErrorCV,
IntCV,
UIntCV,
StringAsciiCV,
StringUtf8CV,
ContractPrincipalCV,
StandardPrincipalCV,
ListCV,
TupleCV,
BufferCV,
TrueCV,
FalseCV,
BooleanCV,
cvToString,
} from "@stacks/transactions";
import { MatcherState } from "@vitest/expect";
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json
// @ts-ignore
BigInt.prototype.toJSON = function () {
return this.toString();
};
function notStr(isNot: boolean) {
return isNot ? "not " : "";
}
function formatMessage(this: MatcherState, received: string, expected: string) {
return `expected ${received} ${notStr(this.isNot)}to be ${expected}`;
}
export class ClarityTypeError extends Error {
actual?: any;
expected?: any;
constructor({ message, actual, expected }: { message: string; actual?: any; expected?: any }) {
super(message);
this.actual = actual;
this.expected = expected;
Object.setPrototypeOf(this, ClarityTypeError.prototype);
}
}
type ClarityTypetoValue = {
[ClarityType.OptionalNone]: NoneCV;
[ClarityType.OptionalSome]: SomeCV;
[ClarityType.ResponseOk]: ResponseOkCV;
[ClarityType.ResponseErr]: ResponseErrorCV;
[ClarityType.BoolTrue]: TrueCV;
[ClarityType.BoolFalse]: FalseCV;
[ClarityType.Int]: IntCV;
[ClarityType.UInt]: UIntCV;
[ClarityType.StringASCII]: StringAsciiCV;
[ClarityType.StringUTF8]: StringUtf8CV;
[ClarityType.PrincipalStandard]: StandardPrincipalCV;
[ClarityType.PrincipalContract]: ContractPrincipalCV;
[ClarityType.List]: ListCV;
[ClarityType.Tuple]: TupleCV;
[ClarityType.Buffer]: BufferCV;
};
const ClarityTypeReversed = Object.fromEntries(Object.entries(ClarityType).map(([k, v]) => [v, k]));
// the "simple clarity values" are CVs that can't be nested and have `value` property
type SimpleCV = BooleanCV | IntCV | UIntCV | StringAsciiCV | StringUtf8CV;
type SimpleCVTypes =
| ClarityType.BoolFalse
| ClarityType.BoolTrue
| ClarityType.Int
| ClarityType.UInt
| ClarityType.StringASCII
| ClarityType.StringUTF8;
const validClarityTypes = Object.values(ClarityType).filter(
(t) => typeof t === "string",
) as string[];
function isClarityValue(input: unknown): input is ClarityValue {
if (!input || typeof input !== "object") return false;
if (!("type" in input) || typeof input.type !== "string") return false;
if (!validClarityTypes.includes(input.type)) return false;
return true;
}
function isClarityValueWithType<T extends ClarityType>(
input: unknown,
withType: T,
): input is ClarityTypetoValue[T] {
if (!isClarityValue(input)) return false;
if (input.type !== withType) return false;
return true;
}
function checkCVType<T extends ClarityType>(
actual: unknown,
expectedType: T,
isNot: boolean,
): actual is ClarityTypetoValue[T] {
const isCV = isClarityValue(actual);
if (!isCV) {
throw new ClarityTypeError({
message: `actual value must ${notStr(isNot)}be a Clarity "${
ClarityTypeReversed[expectedType]
}", received "${typeof actual}"`,
});
}
const isCVWithType = isClarityValueWithType(actual, expectedType);
if (!isCVWithType) {
// for readability, the error diff is kept short if the developers uses the wrong `expect<ClarityType>`
// ideally, we should have a way to display short message diffs even if the actual and/or expected data are big lists/tuples/buffers
// for now, we make an exception and display the full error message if the actual value is a ResponseErr
const errorCode = actual.type === ClarityType.ResponseErr ? ` ${Cl.prettyPrint(actual)}` : "";
throw new ClarityTypeError({
// generic and short message
message: `actual value must ${notStr(isNot)}be a Clarity "${
ClarityTypeReversed[expectedType]
}", received "${ClarityTypeReversed[actual.type]}"${errorCode}`,
actual: ClarityTypeReversed[actual.type],
expected: ClarityTypeReversed[expectedType],
});
}
return true;
}
function errorToAssertionResult(this: MatcherState, err: any) {
return {
pass: false,
message: () => err.message,
actual: err.actual,
expected: err.expected,
};
}
function simpleAssertion(
this: MatcherState,
cvType: SimpleCVTypes,
actualRaw: unknown,
expectedRaw: SimpleCV,
) {
try {
const isCV = checkCVType(actualRaw, cvType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
return {
pass: this.equals(actualRaw, expectedRaw, undefined, true),
message: () =>
`expected ${Cl.prettyPrint(actualRaw)} ${notStr(this.isNot)}to be ${Cl.prettyPrint(
expectedRaw,
)}`,
actual: Cl.prettyPrint(actualRaw, 2),
expected: Cl.prettyPrint(expectedRaw, 2),
};
}
const typeToCvMethod = {
[ClarityType.ResponseOk]: Cl.ok,
[ClarityType.ResponseErr]: Cl.error,
[ClarityType.OptionalSome]: Cl.some,
};
// simple composite types are `ok`, `err`, `some`
function simpleCompositeAssertion(
this: MatcherState,
expectedType: ClarityType.ResponseOk | ClarityType.ResponseErr | ClarityType.OptionalSome,
actualRaw: unknown,
expectedValue: ClarityValue | ExpectStatic,
) {
try {
const isCV = checkCVType(actualRaw, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
const clMethod = typeToCvMethod[expectedType];
const expectedIsCV = isClarityValue(expectedValue);
const expectedOneLine = expectedIsCV
? Cl.prettyPrint(clMethod(expectedValue))
: JSON.stringify(expectedValue);
const expected = expectedIsCV
? Cl.prettyPrint(clMethod(expectedValue), 2)
: JSON.stringify(expectedValue);
return {
pass: this.equals(actualRaw.value, expectedValue, undefined, true),
message: () => formatMessage.call(this, Cl.prettyPrint(actualRaw), expectedOneLine),
actual: Cl.prettyPrint(actualRaw, 2),
expected,
};
}
expect.extend({
toHaveClarityType(actual: unknown, expectedType: ClarityType) {
try {
const isCV = checkCVType(actual, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
return {
pass: true,
message: () =>
`actual value must ${notStr(this.isNot)}be a Clarity "${ClarityTypeReversed[expectedType]}"`,
};
},
toBeBool(actual: unknown, expected: boolean) {
const expectedType = expected ? ClarityType.BoolTrue : ClarityType.BoolFalse;
return simpleAssertion.call(this, expectedType, actual, Cl.bool(expected));
},
toBeInt(actual: unknown, expected: number | bigint) {
return simpleAssertion.call(this, ClarityType.Int, actual, Cl.int(expected));
},
toBeUint(actual: unknown, expected: number | bigint) {
return simpleAssertion.call(this, ClarityType.UInt, actual, Cl.uint(expected));
},
toBeAscii(actual: unknown, expected: string) {
return simpleAssertion.call(this, ClarityType.StringASCII, actual, Cl.stringAscii(expected));
},
toBeUtf8(actual: unknown, expected: string) {
return simpleAssertion.call(this, ClarityType.StringUTF8, actual, Cl.stringUtf8(expected));
},
toBeOk(actual: unknown, expectedValue: ExpectStatic | ClarityValue) {
return simpleCompositeAssertion.call(this, ClarityType.ResponseOk, actual, expectedValue);
},
toBeErr(actual: unknown, expectedValue: ExpectStatic | ClarityValue) {
return simpleCompositeAssertion.call(this, ClarityType.ResponseErr, actual, expectedValue);
},
toBeSome(actual: unknown, expectedValue: ExpectStatic | ClarityValue) {
return simpleCompositeAssertion.call(this, ClarityType.OptionalSome, actual, expectedValue);
},
toBeNone(actual: unknown) {
const expectedType = ClarityType.OptionalNone;
try {
const isCV = checkCVType(actual, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
const expected = Cl.none();
return {
pass: this.equals(actual, expected, undefined, true),
message: () => formatMessage.call(this, Cl.prettyPrint(actual), Cl.prettyPrint(actual)),
actual: Cl.prettyPrint(actual, 2),
expected: Cl.prettyPrint(actual, 2),
};
},
toBePrincipal(actual: unknown, expectedString: string) {
const isStandard = !expectedString.includes(".");
let expected: StandardPrincipalCV | ContractPrincipalCV;
const expectedType = isStandard ? ClarityType.PrincipalStandard : ClarityType.PrincipalContract;
try {
const isCV = checkCVType(actual, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
const actualString = cvToString(actual, "tryAscii");
try {
expected = isStandard
? Cl.standardPrincipal(expectedString)
: Cl.contractPrincipal(...(expectedString.split(".") as [string, string]));
} catch {
return {
pass: false,
message: () => `expected ${expectedString} ${notStr(this.isNot)}to be a principal`,
actual: actualString,
expected: expectedString,
};
}
return {
pass: this.equals(actual, expected, undefined, true),
message: () => formatMessage.call(this, actualString, expectedString),
actual: Cl.prettyPrint(actual, 2),
expected: Cl.prettyPrint(expected, 2),
};
},
toBeBuff(actual: unknown, expectedRaw: Uint8Array) {
const expectedType = ClarityType.Buffer;
try {
const isCV = checkCVType(actual, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
const expected = Cl.buffer(expectedRaw);
return {
pass: this.equals(actual, expected, undefined, true),
// note: throw a simple message and rely on `actual` and `expected` to display the diff
message: () => `the received Buffer does ${this.isNot ? "" : "not "}match the expected one`,
actual: Cl.prettyPrint(actual, 2),
expected: Cl.prettyPrint(expected, 2),
};
},
toBeList(actual: unknown, expectedItems: ExpectStatic[] | ClarityValue[]) {
const expectedType = ClarityType.List;
try {
const isCV = checkCVType(actual, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
const isListArray = checkIsListArray(expectedItems);
const expected = isListArray ? Cl.prettyPrint(Cl.list(expectedItems), 2) : expectedItems;
return {
pass: this.equals(actual.value, expectedItems, undefined, true),
// note: throw a simple message and rely on `actual` and `expected` to display the diff
message: () => `the received List does ${this.isNot ? "" : "not "}match the expected one`,
actual: Cl.prettyPrint(actual, 2),
expected,
};
},
toBeTuple(actual: unknown, expectedData: Record<string, ExpectStatic | ClarityValue>) {
const expectedType = ClarityType.Tuple;
try {
const isCV = checkCVType(actual, expectedType, this.isNot);
assert(isCV);
} catch (e: any) {
return errorToAssertionResult.call(this, e);
}
const isTupleData = checkIsTupleData(expectedData);
const expected = isTupleData ? Cl.prettyPrint(Cl.tuple(expectedData), 2) : expectedData;
return {
pass: this.equals(actual.value, expectedData, undefined, true),
// note: throw a simple message and rely on `actual` and `expected` to display the diff
message: () => `the received Tuple does ${this.isNot ? "" : "not "}match the expected one`,
actual: Cl.prettyPrint(actual, 2),
expected,
};
},
});
// for composite types, matchers need to narrow the type of the expected value
// to know if it contains AsymmetricMatchers or if it's only ClarityValues
function checkIsTupleData(
expected: Record<string, ExpectStatic | ClarityValue>,
): expected is Record<string, ClarityValue> {
return Object.values(expected).every((v) => isClarityValue(v));
}
function checkIsListArray(expected: ExpectStatic[] | ClarityValue[]): expected is ClarityValue[] {
return expected.every((v) => isClarityValue(v));
}