mirror of
https://github.com/penpot/penpot.git
synced 2026-08-09 06:18:47 +00:00
286 lines
8.9 KiB
TypeScript
286 lines
8.9 KiB
TypeScript
/**
|
|
* Minimal, dependency-free jest-like assertion library. It must not rely on any
|
|
* Node/browser globals beyond the basics because it runs inside the SES plugin
|
|
* sandbox. Every failed matcher throws an {@link AssertionError}; the runner
|
|
* turns that into a red test with the message attached.
|
|
*
|
|
* Two properties matter for coverage correctness:
|
|
* - Failure messages are built lazily (only when an assertion fails), so passing
|
|
* assertions never touch the value's members.
|
|
* - `stringify` never enumerates non-plain objects (e.g. the recording proxies
|
|
* used for API coverage). Otherwise `JSON.stringify` would walk every property
|
|
* of a shape and inflate coverage with members the test never used.
|
|
*/
|
|
|
|
export class AssertionError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'AssertionError';
|
|
}
|
|
}
|
|
|
|
function isPlainObject(value: object): boolean {
|
|
const proto = Object.getPrototypeOf(value);
|
|
return proto === Object.prototype || proto === null;
|
|
}
|
|
|
|
function stringify(value: unknown): string {
|
|
if (typeof value === 'string') return JSON.stringify(value);
|
|
if (typeof value === 'bigint') return `${value}n`;
|
|
if (typeof value === 'function')
|
|
return `[Function ${value.name || 'anonymous'}]`;
|
|
if (value === undefined) return 'undefined';
|
|
if (value === null) return 'null';
|
|
// Only enumerate plain objects/arrays. Host/proxy objects (e.g. Penpot shape
|
|
// proxies) are rendered opaquely so stringifying never reads their members.
|
|
if (
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
!isPlainObject(value)
|
|
) {
|
|
return '[object]';
|
|
}
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
function deepEqual(a: unknown, b: unknown): boolean {
|
|
if (Object.is(a, b)) return true;
|
|
if (
|
|
typeof a !== 'object' ||
|
|
typeof b !== 'object' ||
|
|
a === null ||
|
|
b === null
|
|
) {
|
|
return false;
|
|
}
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
|
|
const aKeys = Object.keys(a as Record<string, unknown>);
|
|
const bKeys = Object.keys(b as Record<string, unknown>);
|
|
if (aKeys.length !== bKeys.length) return false;
|
|
|
|
return aKeys.every(
|
|
(key) =>
|
|
Object.prototype.hasOwnProperty.call(b, key) &&
|
|
deepEqual(
|
|
(a as Record<string, unknown>)[key],
|
|
(b as Record<string, unknown>)[key],
|
|
),
|
|
);
|
|
}
|
|
|
|
export interface Matchers {
|
|
toBe(expected: unknown): void;
|
|
toEqual(expected: unknown): void;
|
|
toBeTruthy(): void;
|
|
toBeFalsy(): void;
|
|
toBeNull(): void;
|
|
toBeUndefined(): void;
|
|
toBeDefined(): void;
|
|
toContain(item: unknown): void;
|
|
toHaveLength(length: number): void;
|
|
toBeGreaterThan(n: number): void;
|
|
toBeLessThan(n: number): void;
|
|
toBeCloseTo(n: number, numDigits?: number): void;
|
|
toThrow(expected?: string | RegExp): void;
|
|
}
|
|
|
|
export interface Expectation extends Matchers {
|
|
not: Matchers;
|
|
}
|
|
|
|
type Message = () => string;
|
|
|
|
function errorMessage(thrown: unknown): string {
|
|
return thrown instanceof Error ? thrown.message : String(thrown);
|
|
}
|
|
|
|
function messageMatches(message: string, expected?: string | RegExp): boolean {
|
|
if (typeof expected === 'string') return message.includes(expected);
|
|
if (expected instanceof RegExp) return expected.test(message);
|
|
return true;
|
|
}
|
|
|
|
function makeMatchers(actual: unknown, negate: boolean): Matchers {
|
|
// Message factories are only invoked on failure, so passing assertions never
|
|
// stringify `actual` (which would enumerate proxies and skew coverage).
|
|
const check = (pass: boolean, message: Message, negatedMessage: Message) => {
|
|
if (negate ? pass : !pass) {
|
|
throw new AssertionError((negate ? negatedMessage : message)());
|
|
}
|
|
};
|
|
|
|
return {
|
|
toBe(expected) {
|
|
check(
|
|
Object.is(actual, expected),
|
|
() => `Expected ${stringify(actual)} to be ${stringify(expected)}`,
|
|
() => `Expected ${stringify(actual)} not to be ${stringify(expected)}`,
|
|
);
|
|
},
|
|
toEqual(expected) {
|
|
check(
|
|
deepEqual(actual, expected),
|
|
() => `Expected ${stringify(actual)} to equal ${stringify(expected)}`,
|
|
() =>
|
|
`Expected ${stringify(actual)} not to equal ${stringify(expected)}`,
|
|
);
|
|
},
|
|
toBeTruthy() {
|
|
check(
|
|
!!actual,
|
|
() => `Expected ${stringify(actual)} to be truthy`,
|
|
() => `Expected ${stringify(actual)} not to be truthy`,
|
|
);
|
|
},
|
|
toBeFalsy() {
|
|
check(
|
|
!actual,
|
|
() => `Expected ${stringify(actual)} to be falsy`,
|
|
() => `Expected ${stringify(actual)} not to be falsy`,
|
|
);
|
|
},
|
|
toBeNull() {
|
|
check(
|
|
actual === null,
|
|
() => `Expected ${stringify(actual)} to be null`,
|
|
() => `Expected ${stringify(actual)} not to be null`,
|
|
);
|
|
},
|
|
toBeUndefined() {
|
|
check(
|
|
actual === undefined,
|
|
() => `Expected ${stringify(actual)} to be undefined`,
|
|
() => `Expected ${stringify(actual)} not to be undefined`,
|
|
);
|
|
},
|
|
toBeDefined() {
|
|
check(
|
|
actual !== undefined,
|
|
() => 'Expected value to be defined',
|
|
() => 'Expected value not to be defined',
|
|
);
|
|
},
|
|
toContain(item) {
|
|
const pass =
|
|
(typeof actual === 'string' && actual.includes(String(item))) ||
|
|
(Array.isArray(actual) && actual.includes(item));
|
|
check(
|
|
pass,
|
|
() => `Expected ${stringify(actual)} to contain ${stringify(item)}`,
|
|
() => `Expected ${stringify(actual)} not to contain ${stringify(item)}`,
|
|
);
|
|
},
|
|
toHaveLength(length) {
|
|
const actualLength = (actual as { length?: number })?.length;
|
|
check(
|
|
actualLength === length,
|
|
() =>
|
|
`Expected ${stringify(actual)} to have length ${length} but got ${actualLength}`,
|
|
() => `Expected ${stringify(actual)} not to have length ${length}`,
|
|
);
|
|
},
|
|
toBeGreaterThan(n) {
|
|
check(
|
|
typeof actual === 'number' && actual > n,
|
|
() => `Expected ${stringify(actual)} to be greater than ${n}`,
|
|
() => `Expected ${stringify(actual)} not to be greater than ${n}`,
|
|
);
|
|
},
|
|
toBeLessThan(n) {
|
|
check(
|
|
typeof actual === 'number' && actual < n,
|
|
() => `Expected ${stringify(actual)} to be less than ${n}`,
|
|
() => `Expected ${stringify(actual)} not to be less than ${n}`,
|
|
);
|
|
},
|
|
toBeCloseTo(n, numDigits = 2) {
|
|
const pass =
|
|
typeof actual === 'number' &&
|
|
Math.abs(actual - n) < Math.pow(10, -numDigits) / 2;
|
|
check(
|
|
pass,
|
|
() =>
|
|
`Expected ${stringify(actual)} to be close to ${n} (${numDigits} digits)`,
|
|
() =>
|
|
`Expected ${stringify(actual)} not to be close to ${n} (${numDigits} digits)`,
|
|
);
|
|
},
|
|
toThrow(expected) {
|
|
if (typeof actual !== 'function') {
|
|
throw new AssertionError(
|
|
`Expected a function to call but got ${stringify(actual)}`,
|
|
);
|
|
}
|
|
let thrown: unknown;
|
|
let didThrow = false;
|
|
try {
|
|
(actual as () => unknown)();
|
|
} catch (err) {
|
|
didThrow = true;
|
|
thrown = err;
|
|
}
|
|
if (!didThrow) {
|
|
check(
|
|
false,
|
|
() => 'Expected function to throw',
|
|
() => 'Expected function not to throw',
|
|
);
|
|
return;
|
|
}
|
|
const message = errorMessage(thrown);
|
|
const matches = messageMatches(message, expected);
|
|
check(
|
|
matches,
|
|
() =>
|
|
`Expected function to throw matching ${stringify(expected)} but threw ${stringify(message)}`,
|
|
() => `Expected function not to throw matching ${stringify(expected)}`,
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function expect(actual: unknown): Expectation {
|
|
const matchers = makeMatchers(actual, false) as Expectation;
|
|
matchers.not = makeMatchers(actual, true);
|
|
return matchers;
|
|
}
|
|
|
|
/**
|
|
* Async counterpart to {@link Matchers.toThrow}: awaits a promise (or a 0-arg
|
|
* thunk returning one) and asserts that it REJECTS. `toThrow` can't cover this
|
|
* because it calls its argument synchronously, but a large share of edge cases
|
|
* are async (uploads, exports, version/comment/library ops).
|
|
*
|
|
* A thunk that throws synchronously also counts as a rejection, so callers can
|
|
* pass `() => ctx.penpot.someAsyncCall(badArgs)` regardless of whether the
|
|
* failure surfaces before or after the first await. The optional `expected`
|
|
* matches the error message exactly like `toThrow` (string includes / RegExp).
|
|
*/
|
|
export async function expectReject(
|
|
actual: Promise<unknown> | (() => Promise<unknown> | unknown),
|
|
expected?: string | RegExp,
|
|
): Promise<void> {
|
|
let thrown: unknown;
|
|
let didReject = false;
|
|
try {
|
|
await (typeof actual === 'function' ? actual() : actual);
|
|
} catch (err) {
|
|
didReject = true;
|
|
thrown = err;
|
|
}
|
|
if (!didReject) {
|
|
throw new AssertionError('Expected promise to reject but it resolved');
|
|
}
|
|
const message = errorMessage(thrown);
|
|
if (!messageMatches(message, expected)) {
|
|
throw new AssertionError(
|
|
`Expected promise to reject matching ${stringify(expected)} but rejected with ${stringify(message)}`,
|
|
);
|
|
}
|
|
}
|