Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/runtime/expect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface DeviceAssertion {
interface DeviceExpect extends NativeScriptExpect {
(actual: unknown, message?: string): DeviceAssertion;
objectContaining(sample: Record<string, unknown>): unknown;
assert(value: unknown, message?: string): void;
}

describe('setupNativeScriptExpect', () => {
Expand All @@ -37,4 +38,25 @@ describe('setupNativeScriptExpect', () => {

expect(deviceExpect.getState().assertionCalls).toBe(2);
});

it("provides Vitest's static expect helpers", () => {
const deviceExpect = setupNativeScriptExpect() as DeviceExpect;

expect(() => deviceExpect.unreachable('cleanup')).toThrow(
'expected "cleanup" not to be reached',
);
expect(() => deviceExpect.assert(false, 'not true')).toThrow('not true');

class Version {
constructor(readonly text: string) {}
}
deviceExpect.addEqualityTesters([
(a: unknown, b: unknown) =>
a instanceof Version && b instanceof Version
? a.text.split('.').map(Number).join('.') ===
b.text.split('.').map(Number).join('.')
: undefined,
]);
deviceExpect(new Version('1.02')).toEqual(new Version('1.2'));
});
});
139 changes: 124 additions & 15 deletions src/runtime/expect.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import './web-event-polyfill.js';
import { chai as assertionLibrary } from '@vitest/expect';
import * as vitestExpectModule from '@vitest/expect';
import { getCurrentTest, type Test } from '@vitest/runner';

type AssertionPlugin = (library: unknown, utils: unknown) => void;

interface Assertion {
withTest(test: Test): Assertion;
withContext(context: Record<string, unknown>): Assertion;
}

interface AssertionRuntime {
config: { useProxy: boolean };
use(plugin: AssertionPlugin): void;
assert: { fail(message: string): never };
expect: NativeScriptExpect & {
extend(expect: unknown, matchers: Record<string, unknown>): void;
};
Expand All @@ -19,25 +26,48 @@ interface VitestExpectRuntime {
GLOBAL_EXPECT: symbol;
JEST_MATCHERS_OBJECT: symbol;
ASYMMETRIC_MATCHERS_OBJECT: symbol;
customMatchers: Record<string, unknown>;
getState(expect: unknown): Record<string, unknown>;
setState(state: Record<string, unknown>, expect: unknown): void;
addCustomEqualityTesters(testers: unknown[]): void;
}

type AssertionState = {
assertionCalls: number;
isExpectingAssertions: boolean;
isExpectingAssertionsError: Error | null;
expectedAssertionsNumber: number | null;
expectedAssertionsNumberErrorGen: (() => Error) | null;
};

export interface NativeScriptExpect {
(actual: unknown, message?: string): unknown;
getState(): Record<string, unknown>;
setState(state: Record<string, unknown>): void;
extend(matchers: Record<string, unknown>): void;
soft(actual: unknown, message?: string): unknown;
assertions(expected: number): void;
hasAssertions(): void;
unreachable(message?: string): never;
addEqualityTesters(testers: unknown[]): void;
[key: string]: unknown;
}

const assertionRuntime = assertionLibrary as unknown as AssertionRuntime;
const vitestExpect = vitestExpectModule as unknown as VitestExpectRuntime;
let currentExpect: NativeScriptExpect | undefined;

export function setupNativeScriptExpect(): NativeScriptExpect {
if (currentExpect) return currentExpect;
function initialAssertionState(): AssertionState {
return {
assertionCalls: 0,
isExpectingAssertions: false,
isExpectingAssertionsError: null,
expectedAssertionsNumber: null,
expectedAssertionsNumberErrorGen: null,
};
}

function prepareAssertionLibrary(): void {
const symbolGlobals = globalThis as unknown as Record<symbol, unknown>;
if (!symbolGlobals[vitestExpect.JEST_MATCHERS_OBJECT]) {
symbolGlobals[vitestExpect.JEST_MATCHERS_OBJECT] = {
Expand All @@ -53,35 +83,77 @@ export function setupNativeScriptExpect(): NativeScriptExpect {
assertionRuntime.use(vitestExpect.JestChaiExpect);
assertionRuntime.use(vitestExpect.JestAsymmetricMatchers);
assertionRuntime.use(vitestExpect.JestExtend);
}

/**
* Creates an `expect` with its own assertion state, like the one Vitest puts
* on a test's context. Its assertions belong to `test`, or to the test running
* when they are made, which lets the runner await or record their failures.
* Matchers and `expect.extend` stay shared.
*/
export function createNativeScriptExpect(test?: Test): NativeScriptExpect {
prepareAssertionLibrary();

const expect = ((actual: unknown, message?: string): unknown => {
const state = vitestExpect.getState(expect);
const assertionCalls = Number(state.assertionCalls ?? 0);
vitestExpect.setState({ assertionCalls: assertionCalls + 1 }, expect);
return assertionRuntime.expect(actual, message);
const assertion = assertionRuntime.expect(actual, message) as Assertion;
const owner = test ?? getCurrentTest();
return owner ? assertion.withTest(owner) : assertion;
}) as NativeScriptExpect;

Object.assign(expect, assertionRuntime.expect);
Object.assign(
expect,
symbolGlobals[vitestExpect.ASYMMETRIC_MATCHERS_OBJECT] as object,
(globalThis as unknown as Record<symbol, object>)[
vitestExpect.ASYMMETRIC_MATCHERS_OBJECT
],
);
expect.getState = () => vitestExpect.getState(expect);
expect.setState = (state) => vitestExpect.setState(state, expect);
expect.extend = (matchers) =>
assertionRuntime.expect.extend(expect, matchers);
vitestExpect.setState(
{
assertionCalls: 0,
isExpectingAssertions: false,
isExpectingAssertionsError: null,
expectedAssertionsNumber: null,
expectedAssertionsNumberErrorGen: null,
},
expect,
);
expect.soft = (actual, message) =>
(expect(actual, message) as Assertion).withContext({ soft: true });
expect.assert = assertionRuntime.assert;
expect.addEqualityTesters = (testers) =>
vitestExpect.addCustomEqualityTesters(testers);
expect.unreachable = (message) =>
assertionRuntime.assert.fail(
`expected${message ? ` "${message}" ` : ' '}not to be reached`,
);
expect.assertions = (expected) => {
expect.setState({
expectedAssertionsNumber: expected,
expectedAssertionsNumberErrorGen: () =>
new Error(
`expected number of assertions to be ${expected}, but got ${String(
expect.getState().assertionCalls,
)}`,
),
});
};
expect.hasAssertions = () => {
expect.setState({
isExpectingAssertions: true,
isExpectingAssertionsError: new Error(
'expected any number of assertion, but got none',
),
});
};
expect.extend(vitestExpect.customMatchers);
vitestExpect.setState(initialAssertionState(), expect);
return expect;
}

symbolGlobals[vitestExpect.GLOBAL_EXPECT] = expect;
export function setupNativeScriptExpect(): NativeScriptExpect {
if (currentExpect) return currentExpect;

const expect = createNativeScriptExpect();
(globalThis as unknown as Record<symbol, unknown>)[
vitestExpect.GLOBAL_EXPECT
] = expect;
(globalThis as unknown as { expect?: NativeScriptExpect }).expect = expect;
currentExpect = expect;
return expect;
Expand All @@ -90,3 +162,40 @@ export function setupNativeScriptExpect(): NativeScriptExpect {
export function getNativeScriptExpect(): NativeScriptExpect {
return currentExpect ?? setupNativeScriptExpect();
}

/** Resets the assertion state that `verifyNativeScriptAssertions` checks. */
export function resetNativeScriptAssertions(expect: NativeScriptExpect): void {
expect.setState(initialAssertionState());
}

/**
* Throws the error Vitest reports when a test attempt made fewer or more
* assertions through `expect` than `expect.assertions`,
* `expect.hasAssertions` or the `expect.requireAssertions` option asked for.
*/
export function verifyNativeScriptAssertions(
expect: NativeScriptExpect,
requireAssertions: boolean,
): void {
const {
assertionCalls,
expectedAssertionsNumber,
expectedAssertionsNumberErrorGen,
isExpectingAssertions,
isExpectingAssertionsError,
} = expect.getState() as unknown as AssertionState;

if (
expectedAssertionsNumber !== null &&
assertionCalls !== expectedAssertionsNumber &&
expectedAssertionsNumberErrorGen
) {
throw expectedAssertionsNumberErrorGen();
}
if (isExpectingAssertions && assertionCalls === 0) {
throw isExpectingAssertionsError;
}
if (requireAssertions && assertionCalls === 0) {
throw new Error('expected any number of assertion, but got none');
}
}
Loading