packages/@alicloud/ros-cdk-assert/lib/assertions/have-resource.ts (262 lines of code) (raw):

import { Assertion, JestFriendlyAssertion } from '../assertion'; import { StackInspector } from '../inspector'; /** * Magic value to signify that a certain key should be absent from the property bag. * * The property is either not present or set to `undefined. * * NOTE: `ABSENT` only works with the `haveResource()` and `haveResourceLike()` * assertions. */ export const ABSENT = '{{ABSENT}}'; /** * An assertion to check whether a resource of a given type and with the given properties exists, disregarding properties * * @param resourceType the type of the resource that is expected to be present. * @param properties the properties that the resource is expected to have. A function may be provided, in which case * it will be called with the properties of candidate resources and an ``InspectionFailure`` * instance on which errors should be appended, and should return a truthy value to denote a match. * @param comparison the entity that is being asserted against. * @param allowValueExtension if properties is an object, tells whether values must match exactly, or if they are * allowed to be supersets of the reference values. Meaningless if properties is a function. */ export function haveResource( resourceType: string, properties?: any, comparison?: ResourcePart, allowValueExtension: boolean = false, ): Assertion<StackInspector> { return new HaveResourceAssertion(resourceType, properties, comparison, allowValueExtension); } /** * Sugar for calling ``haveResources`` with ``allowValueExtension`` set to ``true``. */ export function haveResourceLike(resourceType: string, properties?: any, comparison?: ResourcePart) { return haveResource(resourceType, properties, comparison, true); } export type PropertyMatcher = (props: any, inspection: InspectionFailure) => boolean; export class HaveResourceAssertion extends JestFriendlyAssertion<StackInspector> { private readonly inspected: InspectionFailure[] = []; private readonly part: ResourcePart; private readonly matcher: any; constructor( private readonly resourceType: string, properties?: any, part?: ResourcePart, allowValueExtension: boolean = false, ) { super(); this.matcher = isCallable(properties) ? properties : properties === undefined ? anything() : allowValueExtension ? deepObjectLike(properties) : objectLike(properties); this.part = part !== undefined ? part : ResourcePart.Properties; } public assertUsing(inspector: StackInspector): boolean { for (const logicalId of Object.keys(inspector.value.Resources || {})) { const resource = inspector.value.Resources[logicalId]; if (resource.Type === this.resourceType) { const propsToCheck = this.part === ResourcePart.Properties ? resource.Properties : resource; // Pass inspection object as 2nd argument, initialize failure with default string, // to maintain backwards compatibility with old predicate API. const inspection = { resource, failureReason: 'Object did not match predicate' }; if (match(propsToCheck, this.matcher, inspection)) { return true; } this.inspected.push(inspection); } } return false; } public generateErrorMessage() { const lines: string[] = []; lines.push(`None of ${this.inspected.length} resources matches ${this.description}.`); for (const inspected of this.inspected) { lines.push(`- ${inspected.failureReason} in:`); lines.push(indent(4, JSON.stringify(inspected.resource, null, 2))); } return lines.join('\n'); } public assertOrThrow(inspector: StackInspector) { if (!this.assertUsing(inspector)) { throw new Error(this.generateErrorMessage()); } } public get description(): string { // tslint:disable-next-line:max-line-length return `resource '${this.resourceType}' with ${JSON.stringify(this.matcher, undefined, 2)}`; } } function indent(n: number, s: string) { const prefix = ' '.repeat(n); return prefix + s.replace(/\n/g, '\n' + prefix); } export interface InspectionFailure { resource: any; failureReason: string; } /** * Match a given literal value against a matcher * * If the matcher is a callable, use that to evaluate the value. Otherwise, the values * must be literally the same. */ function match(value: any, matcher: any, inspection: InspectionFailure) { if (isCallable(matcher)) { // Custom matcher (this mostly looks very weird because our `InspectionFailure` signature is weird) const innerInspection: InspectionFailure = { ...inspection, failureReason: '' }; const result = matcher(value, innerInspection); if (typeof result !== 'boolean') { return failMatcher(inspection, `Predicate returned non-boolean return value: ${result}`); } if (!result && !innerInspection.failureReason) { // Custom matcher neglected to return an error return failMatcher(inspection, 'Predicate returned false'); } // Propagate inner error in case of failure if (!result) { inspection.failureReason = innerInspection.failureReason; } return result; } return matchLiteral(value, matcher, inspection); } /** * Match a literal value at the top level. * * When recursing into arrays or objects, the nested values can be either matchers * or literals. */ function matchLiteral(value: any, pattern: any, inspection: InspectionFailure) { if (pattern == null) { return true; } const errors = new Array<string>(); if (Array.isArray(value) !== Array.isArray(pattern)) { return failMatcher(inspection, 'Array type mismatch'); } if (Array.isArray(value)) { if (pattern.length !== value.length) { return failMatcher(inspection, 'Array length mismatch'); } // Recurse comparison for individual objects for (let i = 0; i < pattern.length; i++) { if (!match(value[i], pattern[i], { ...inspection })) { errors.push(`Array element ${i} mismatch`); } } if (errors.length > 0) { return failMatcher(inspection, errors.join(', ')); } return true; } if ((typeof value === 'object') !== (typeof pattern === 'object')) { return failMatcher(inspection, 'Object type mismatch'); } if (typeof pattern === 'object') { // Check that all fields in the pattern have the right value const innerInspection = { ...inspection, failureReason: '' }; const matcher = objectLike(pattern)(value, innerInspection); if (!matcher) { inspection.failureReason = innerInspection.failureReason; return false; } // Check no fields uncovered const realFields = new Set(Object.keys(value)); for (const key of Object.keys(pattern)) { realFields.delete(key); } if (realFields.size > 0) { return failMatcher(inspection, `Unexpected keys present in object: ${Array.from(realFields).join(', ')}`); } return true; } if (value !== pattern) { return failMatcher(inspection, 'Different values'); } return true; } /** * Helper function to make matcher failure reporting a little easier * * Our protocol is weird (change a string on a passed-in object and return 'false'), * but I don't want to change that right now. */ function failMatcher(inspection: InspectionFailure, error: string): boolean { inspection.failureReason = error; return false; } /** * A matcher for an object that contains at least the given fields with the given matchers (or literals) * * Only does lenient matching one level deep, at the next level all objects must declare the * exact expected keys again. */ export function objectLike<A extends object>(pattern: A): PropertyMatcher { return _objectContaining(pattern, false); } /** * A matcher for an object that contains at least the given fields with the given matchers (or literals) * * Switches to "deep" lenient matching. Nested objects also only need to contain declared keys. */ export function deepObjectLike<A extends object>(pattern: A): PropertyMatcher { return _objectContaining(pattern, true); } export function _objectContaining<A extends object>(pattern: A, deep: boolean): PropertyMatcher { const ret = (value: any, inspection: InspectionFailure): boolean => { if (typeof value !== 'object' || !value) { return failMatcher(inspection, `Expect an object but got '${typeof value}'`); } const errors = new Array<string>(); for (const [patternKey, patternValue] of Object.entries(pattern)) { if (patternValue === ABSENT) { if (value[patternKey] !== undefined) { errors.push(`Field ${patternKey} present, but shouldn't be`); } continue; } if (!(patternKey in value)) { errors.push(`Field ${patternKey} missing`); continue; } // If we are doing DEEP objectLike, translate object literals in the pattern into // more `deepObjectLike` matchers, even if they occur in lists. const matchValue = deep ? deepMatcherFromObjectLiteral(patternValue) : patternValue; const innerInspection = { ...inspection, failureReason: '' }; const valueMatches = match(value[patternKey], matchValue, innerInspection); if (!valueMatches) { errors.push(`Field ${patternKey} mismatch: ${innerInspection.failureReason}`); } } /** * Transform nested object literals into more deep object matchers, if applicable * * Object literals in lists are also transformed. */ function deepMatcherFromObjectLiteral(nestedPattern: any): any { if (isObject(nestedPattern)) { return deepObjectLike(nestedPattern); } if (Array.isArray(nestedPattern)) { return nestedPattern.map(deepMatcherFromObjectLiteral); } return nestedPattern; } if (errors.length > 0) { return failMatcher(inspection, errors.join(', ')); } return true; }; // Override toJSON so that our error messages print an readable version of this matcher // (which we produce by doing JSON.stringify() at some point in the future). ret.toJSON = () => ({ [deep ? '$deepObjectLike' : '$objectLike']: pattern }); return ret; } /** * Match exactly the given value * * This is the default, you only need this to escape from the deep lenient matching * of `deepObjectLike`. */ export function exactValue(expected: any): PropertyMatcher { const ret = (value: any, inspection: InspectionFailure): boolean => { return matchLiteral(value, expected, inspection); }; // Override toJSON so that our error messages print an readable version of this matcher // (which we produce by doing JSON.stringify() at some point in the future). ret.toJSON = () => ({ $exactValue: expected }); return ret; } /** * A matcher for a list that contains all of the given elements in any order */ export function arrayWith(...elements: any[]): PropertyMatcher { if (elements.length === 0) { return anything(); } const ret = (value: any, inspection: InspectionFailure): boolean => { if (!Array.isArray(value)) { return failMatcher(inspection, `Expect an array but got '${typeof value}'`); } for (const element of elements) { const failure = longestFailure(value, element); if (failure) { return failMatcher( inspection, `Array did not contain expected element, closest match at index ${failure[0]}: ${failure[1]}`, ); } } return true; /** * Return 'null' if the matcher matches anywhere in the array, otherwise the longest error and its index */ function longestFailure(array: any[], matcher: any): [number, string] | null { let fail: [number, string] | null = null; for (let i = 0; i < array.length; i++) { const innerInspection = { ...inspection, failureReason: '' }; if (match(array[i], matcher, innerInspection)) { return null; } if (fail === null || innerInspection.failureReason.length > fail[1].length) { fail = [i, innerInspection.failureReason]; } } return fail; } }; // Override toJSON so that our error messages print an readable version of this matcher // (which we produce by doing JSON.stringify() at some point in the future). ret.toJSON = () => ({ $arrayContaining: elements.length === 1 ? elements[0] : elements }); return ret; } /** * Matches anything */ function anything() { const ret = () => { return true; }; ret.toJSON = () => ({ $anything: true }); return ret; } /** * Return whether `superObj` is a super-object of `obj`. * * A super-object has the same or more property values, recursing into sub properties if ``allowValueExtension`` is true. * * At any point in the object, a value may be replaced with a function which will be used to check that particular field. * The type of a matcher function is expected to be of type PropertyMatcher. * * @deprecated - Use `objectLike` or a literal object instead. */ export function isSuperObject( superObj: any, pattern: any, errors: string[] = [], allowValueExtension: boolean = false, ): boolean { const matcher = allowValueExtension ? deepObjectLike(pattern) : objectLike(pattern); const inspection: InspectionFailure = { resource: superObj, failureReason: '' }; const ret = match(superObj, matcher, inspection); if (!ret) { errors.push(inspection.failureReason); } return ret; } /** * What part of the resource to compare */ export enum ResourcePart { /** * Only compare the resource's properties */ Properties, /** * Check the entire config * * (including UpdateConfig, DependsOn, etc.) */ CompleteDefinition, } /** * Whether a value is a callable */ function isCallable(x: any): x is (...args: any[]) => any { return x && {}.toString.call(x) === '[object Function]'; } /** * Whether a value is an object */ function isObject(x: any): x is object { // Because `typeof null === 'object'`. return x && typeof x === 'object'; }