Skip to content

Commit e53f159

Browse files
committed
chore: split matchers into separate files
1 parent ed8ec74 commit e53f159

File tree

5 files changed

+298
-1
lines changed

5 files changed

+298
-1
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { spyOn } from '@vitest/spy';
2+
3+
function formatConsoleCall(args) {
4+
// Just calling .join suppresses null/undefined, so we stringify separately
5+
return args.map(String).join(' ');
6+
}
7+
8+
function formatAllCalls(argsList) {
9+
return argsList.map((args) => `"${formatConsoleCall(args)}"`).join(', ');
10+
}
11+
12+
function callAndGetLogs(fn, methodName) {
13+
const spy = spyOn(console, methodName).mockImplementation(() => {});
14+
try {
15+
fn();
16+
return spy.mock.calls;
17+
} finally {
18+
spy.mockRestore();
19+
}
20+
}
21+
22+
function consoleMatcherFactory(chai, utils, methodName, expectInProd) {
23+
return function consoleMatcher(expectedMessages) {
24+
const actual = utils.flag(this, 'object');
25+
26+
if (utils.flag(this, 'negate')) {
27+
// If there's a .not in the assertion chain
28+
const callsArgs = callAndGetLogs(actual, methodName);
29+
if (callsArgs.length === 0) {
30+
return;
31+
}
32+
throw new chai.AssertionError(
33+
`Expect no message but received:\n${formatAllCalls(callsArgs)}`
34+
);
35+
}
36+
37+
if (!Array.isArray(expectedMessages)) {
38+
expectedMessages = [expectedMessages];
39+
}
40+
41+
if (typeof actual !== 'function') {
42+
throw new Error('Expected function to throw error.');
43+
} else if (expectedMessages.some((m) => typeof m !== 'string' && !(m instanceof RegExp))) {
44+
throw new Error(
45+
'Expected a string or a RegExp to compare the thrown error against, or an array of such.'
46+
);
47+
}
48+
49+
const callsArgs = callAndGetLogs(actual, methodName);
50+
51+
if (!expectInProd && process.env.NODE_ENV === 'production') {
52+
if (callsArgs.length !== 0) {
53+
throw new chai.AssertionError(
54+
`Expected console.${
55+
methodName
56+
} to never be called in production mode, but it was called ${
57+
callsArgs.length
58+
} time(s) with ${formatAllCalls(callsArgs)}.`
59+
);
60+
}
61+
} else {
62+
if (callsArgs.length === 0) {
63+
// Result: "string", /regex/
64+
const formattedExpected = expectedMessages
65+
.map((msg) => (typeof msg === 'string' ? JSON.stringify(msg) : msg.toString()))
66+
.join(', ');
67+
throw new chai.AssertionError(
68+
`Expected console.${methodName} to be called with [${
69+
formattedExpected
70+
}], but was never called.`
71+
);
72+
} else {
73+
if (callsArgs.length !== expectedMessages.length) {
74+
throw new chai.AssertionError(
75+
`Expected console.${methodName} to be called ${
76+
expectedMessages.length
77+
} time(s), but was called ${callsArgs.length} time(s).`
78+
);
79+
}
80+
for (let i = 0; i < callsArgs.length; i++) {
81+
const callsArg = callsArgs[i];
82+
const expectedMessage = expectedMessages[i];
83+
const actualMessage = formatConsoleCall(callsArg);
84+
85+
const matches =
86+
typeof expectedMessage === 'string'
87+
? actualMessage === expectedMessage
88+
: expectedMessage.test(actualMessage);
89+
if (!matches) {
90+
throw new chai.AssertionError(
91+
`Expected console.${methodName} to be called with "${
92+
expectedMessage
93+
}", but was called with "${actualMessage}".`
94+
);
95+
}
96+
}
97+
}
98+
}
99+
};
100+
}
101+
102+
/**
103+
* Custom console assertions
104+
* @type {Chai.ChaiPlugin}
105+
*/
106+
export const registerConsoleMatchers = (chai, utils) => {
107+
const customMatchers = {
108+
// FIXME: Add descriptions explaining the what/why of these custom matchers
109+
toLogErrorDev: consoleMatcherFactory(chai, utils, 'error'),
110+
toLogError: consoleMatcherFactory(chai, utils, 'error', true),
111+
toLogWarningDev: consoleMatcherFactory(chai, utils, 'warn'),
112+
};
113+
114+
for (const [name, impl] of Object.entries(customMatchers)) {
115+
utils.addMethod(chai.Assertion.prototype, name, impl);
116+
}
117+
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Listen for errors thrown directly by the callback
2+
function directErrorListener(callback) {
3+
try {
4+
callback();
5+
} catch (error) {
6+
return error;
7+
}
8+
}
9+
10+
// Listen for errors using window.addEventListener('error')
11+
function windowErrorListener(callback) {
12+
let error;
13+
function onError(event) {
14+
event.preventDefault(); // don't log the error
15+
error = event.error;
16+
}
17+
18+
// Prevent jasmine from handling the global error. There doesn't seem to be another
19+
// way to disable this behavior: https://github.com/jasmine/jasmine/pull/1860
20+
const originalOnError = window.onerror;
21+
window.onerror = null;
22+
window.addEventListener('error', onError);
23+
24+
try {
25+
callback();
26+
} finally {
27+
window.onerror = originalOnError;
28+
window.removeEventListener('error', onError);
29+
}
30+
return error;
31+
}
32+
33+
// For errors we expect to be thrown in the connectedCallback() phase
34+
// of a custom element, there are two possibilities:
35+
// 1) We're using non-native lifecycle callbacks, so the error is thrown synchronously
36+
// 2) We're using native lifecycle callbacks, so the error is thrown asynchronously and can
37+
// only be caught with window.addEventListener('error')
38+
// - Note native lifecycle callbacks are all thrown asynchronously.
39+
function customElementCallbackReactionErrorListener(callback) {
40+
return lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE
41+
? directErrorListener(callback)
42+
: windowErrorListener(callback);
43+
}
44+
45+
function matchError(error, expectedErrorCtor, expectedMessage) {
46+
if ((!error) instanceof expectedErrorCtor) {
47+
return false;
48+
} else if (typeof expectedMessage === 'undefined') {
49+
return true;
50+
} else if (typeof expectedMessage === 'string') {
51+
return error.message === expectedMessage;
52+
} else {
53+
return expectedMessage.test(error.message);
54+
}
55+
}
56+
57+
function throwDescription(thrown) {
58+
return `${thrown.name} with message "${thrown.message}"`;
59+
}
60+
61+
function errorMatcherFactory(chai, utils, errorListener, expectInProd) {
62+
return function toThrowError(expectedErrorCtor, expectedMessage) {
63+
if (typeof expectedMessage === 'undefined') {
64+
if (typeof expectedErrorCtor === 'undefined') {
65+
// 0 arguments provided
66+
expectedMessage = undefined;
67+
expectedErrorCtor = Error;
68+
} else {
69+
// 1 argument provided
70+
expectedMessage = expectedErrorCtor;
71+
expectedErrorCtor = Error;
72+
}
73+
}
74+
75+
const actual = utils.flag(this, 'object');
76+
if (typeof actual !== 'function') {
77+
throw new Error('Expected function to throw error.');
78+
} else if (expectedErrorCtor !== Error && !(expectedErrorCtor.prototype instanceof Error)) {
79+
throw new Error('Expected an error constructor.');
80+
} else if (
81+
typeof expectedMessage !== 'undefined' &&
82+
typeof expectedMessage !== 'string' &&
83+
!(expectedMessage instanceof RegExp)
84+
) {
85+
throw new Error('Expected a string or a RegExp to compare the thrown error against.');
86+
}
87+
88+
const thrown = errorListener(actual);
89+
90+
if (!expectInProd && process.env.NODE_ENV === 'production') {
91+
if (thrown !== undefined) {
92+
throw new chai.AssertionError(
93+
`Expected function not to throw an error in production mode, but it threw ${throwDescription(
94+
thrown
95+
)}.`
96+
);
97+
}
98+
} else if (thrown === undefined) {
99+
throw new chai.AssertionError(
100+
`Expected function to throw an ${
101+
expectedErrorCtor.name
102+
} error in development mode "${
103+
expectedMessage ? 'with message ' + expectedMessage : ''
104+
}".`
105+
);
106+
} else if (!matchError(thrown, expectedErrorCtor, expectedMessage)) {
107+
throw new chai.AssertionError(
108+
`Expected function to throw an ${
109+
expectedErrorCtor.name
110+
} error in development mode "${
111+
expectedMessage ? 'with message ' + expectedMessage : ''
112+
}", but it threw ${throwDescription(thrown)}.`
113+
);
114+
}
115+
};
116+
}
117+
118+
/** @type {Chai.ChaiPlugin} */
119+
export const registerErrorMatchers = (chai, utils) => {
120+
const matchers = {
121+
toThrowErrorDev: errorMatcherFactory(chai, utils, directErrorListener),
122+
toThrowCallbackReactionErrorDev: errorMatcherFactory(
123+
chai,
124+
utils,
125+
customElementCallbackReactionErrorListener
126+
),
127+
toThrowCallbackReactionError: errorMatcherFactory(
128+
chai,
129+
utils,
130+
customElementCallbackReactionErrorListener,
131+
true
132+
),
133+
toThrowCallbackReactionErrorEvenInSyntheticLifecycleMode: errorMatcherFactory(
134+
chai,
135+
utils,
136+
windowErrorListener,
137+
true
138+
),
139+
};
140+
141+
for (const [name, impl] of Object.entries(matchers)) {
142+
utils.addMethod(chai.Assertion.prototype, name, impl);
143+
}
144+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { registerConsoleMatchers } from './console.mjs';
2+
import { registerErrorMatchers } from './errors.mjs';
3+
import { registerJasmineMatchers } from './jasmine.mjs';
4+
5+
export const registerCustomMatchers = (chai, utils) => {
6+
registerConsoleMatchers(chai, utils);
7+
registerErrorMatchers(chai, utils);
8+
registerJasmineMatchers(chai, utils);
9+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Custom matchers implemented as part of the migration from Karma to Web Test
3+
* Runner. Should be removed and usage replaced with chai assertions.
4+
* @type {Chai.ChaiPlugin}
5+
*/
6+
export const registerJasmineMatchers = (chai, utils) => {
7+
const matchers = {
8+
toHaveSize(size) {
9+
const value = utils.flag(this, 'object');
10+
chai.expect(value).to.have.length(size);
11+
},
12+
toBeFalse() {
13+
const value = utils.flag(this, 'object');
14+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
15+
chai.expect(value).to.be.false;
16+
},
17+
toBeTrue() {
18+
const value = utils.flag(this, 'object');
19+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
20+
chai.expect(value).to.be.true;
21+
},
22+
};
23+
24+
for (const [name, impl] of Object.entries(matchers)) {
25+
utils.addMethod(chai.Assertion.prototype, name, impl);
26+
}
27+
};

packages/@lwc/integration-not-karma/helpers/setup.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { JestAsymmetricMatchers, JestChaiExpect, JestExtend } from '@vitest/expe
33
import * as chai from 'chai';
44
import * as LWC from 'lwc';
55
import { spyOn, fn } from '@vitest/spy';
6-
import { registerCustomMatchers } from './matchers.mjs';
6+
import { registerCustomMatchers } from './matchers/index.mjs';
77

88
// allows using expect.extend instead of chai.use to extend plugins
99
chai.use(JestExtend);

0 commit comments

Comments
 (0)