Skip to content

Commit 1462821

Browse files
committed
test(test-setup): add custom matchers for ui logger
1 parent 053d595 commit 1462821

File tree

4 files changed

+355
-2
lines changed

4 files changed

+355
-2
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { cliui } from '@poppinss/cliui';
2+
import type { SyncExpectationResult } from '@vitest/expect';
3+
import { expect } from 'vitest';
4+
import {
5+
type LogLevel,
6+
extractLevel,
7+
extractMessage,
8+
hasExpectedMessage,
9+
messageContains,
10+
} from './ui-logger.matcher.utils';
11+
12+
type CliUi = ReturnType<typeof cliui>;
13+
14+
export type CustomUiLoggerMatchers = {
15+
toHaveLoggedMessage: (expected: string) => void;
16+
toHaveLoggedNthMessage: (nth: number, expected: string) => void;
17+
toHaveLoggedLevel: (expected: LogLevel) => void;
18+
toHaveLoggedNthLevel: (nth: number, expected: LogLevel) => void;
19+
toHaveLoggedMessageContaining: (expected: string) => void;
20+
toHaveLoggedNthMessageContaining: (nth: number, expected: string) => void;
21+
toHaveLogged: () => void;
22+
toHaveLoggedTimes: (times: number) => void;
23+
};
24+
25+
expect.extend({
26+
toHaveLoggedMessage: assertMessageLogged,
27+
toHaveLoggedNthMessage: assertNthMessageLogged,
28+
toHaveLoggedLevel: assertLevelLogged,
29+
toHaveLoggedNthLevel: assertNthLevelLogged,
30+
toHaveLoggedMessageContaining: assertMessageContaining,
31+
toHaveLoggedNthMessageContaining: assertNthMessageContaining,
32+
toHaveLogged: assertLogs,
33+
toHaveLoggedTimes: assertLogCount,
34+
});
35+
36+
function assertMessageLogged(
37+
actual: CliUi,
38+
expected: string,
39+
): SyncExpectationResult {
40+
const messages = actual.logger
41+
.getRenderer()
42+
.getLogs()
43+
.map(({ message }) => extractMessage(message));
44+
45+
const pass = messages.some(msg => hasExpectedMessage(expected, msg));
46+
return {
47+
pass,
48+
message: () =>
49+
pass
50+
? `Expected not to have logged: ${expected}`
51+
: `Expected to have logged: ${expected}}`,
52+
};
53+
}
54+
55+
function assertNthMessageLogged(
56+
actual: CliUi,
57+
nth: number,
58+
expected: string,
59+
): SyncExpectationResult {
60+
const messages = actual.logger
61+
.getRenderer()
62+
.getLogs()
63+
.map(({ message }) => extractMessage(message));
64+
65+
const pass = hasExpectedMessage(expected, messages[nth - 1]);
66+
return {
67+
pass,
68+
message: () =>
69+
pass
70+
? `Expected not to have logged at position ${nth}: ${expected}`
71+
: `Expected to have logged at position ${nth}: ${expected}`,
72+
};
73+
}
74+
75+
function assertLevelLogged(
76+
actual: CliUi,
77+
expected: LogLevel,
78+
): SyncExpectationResult {
79+
const levels = actual.logger
80+
.getRenderer()
81+
.getLogs()
82+
.map(({ message }) => extractLevel(message));
83+
84+
const pass = levels.includes(expected);
85+
return {
86+
pass,
87+
message: () =>
88+
pass
89+
? `Expected not to have ${expected} log level`
90+
: `Expected to have ${expected} log level`,
91+
};
92+
}
93+
94+
function assertNthLevelLogged(
95+
actual: CliUi,
96+
nth: number,
97+
expected: LogLevel,
98+
): SyncExpectationResult {
99+
const levels = actual.logger
100+
.getRenderer()
101+
.getLogs()
102+
.map(({ message }) => extractLevel(message));
103+
104+
const pass = levels[nth - 1] === expected;
105+
return {
106+
pass,
107+
message: () =>
108+
pass
109+
? `Expected not to have log level at position ${nth}: ${expected}`
110+
: `Expected to have log level at position ${nth}: ${expected}`,
111+
};
112+
}
113+
114+
function assertMessageContaining(
115+
actual: CliUi,
116+
expected: string,
117+
): SyncExpectationResult {
118+
const messages = actual.logger
119+
.getRenderer()
120+
.getLogs()
121+
.map(({ message }) => extractMessage(message));
122+
123+
const pass = messages.some(msg => messageContains(expected, msg));
124+
return {
125+
pass,
126+
message: () =>
127+
pass
128+
? `Expected not to find a message containing: ${expected}`
129+
: `Expected to find a message containing: ${expected}, but none matched.`,
130+
};
131+
}
132+
133+
function assertNthMessageContaining(
134+
actual: CliUi,
135+
nth: number,
136+
expected: string,
137+
): SyncExpectationResult {
138+
const messages = actual.logger
139+
.getRenderer()
140+
.getLogs()
141+
.map(({ message }) => extractMessage(message));
142+
143+
const pass = messageContains(expected, messages[nth - 1]);
144+
return {
145+
pass,
146+
message: () =>
147+
pass
148+
? `Expected not to find the fragment "${expected}" in the message at position ${nth}`
149+
: `Expected to find the fragment "${expected}" in the message at position ${nth}, but it was not found.`,
150+
};
151+
}
152+
153+
function assertLogs(actual: CliUi): SyncExpectationResult {
154+
const logs = actual.logger.getRenderer().getLogs();
155+
156+
const pass = logs.length > 0;
157+
return {
158+
pass,
159+
message: () =>
160+
pass
161+
? `Expected not to have any logs`
162+
: `Expected to have some logs, but no logs were produced`,
163+
};
164+
}
165+
166+
function assertLogCount(
167+
actual: CliUi,
168+
expected: number,
169+
): SyncExpectationResult {
170+
const logs = actual.logger.getRenderer().getLogs();
171+
172+
const pass = logs.length === expected;
173+
return {
174+
pass,
175+
message: () =>
176+
pass
177+
? `Expected not to have exactly ${expected} logs`
178+
: `Expected to have ${expected} logs, but got ${logs.length}`,
179+
};
180+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { LoggingTypes } from '@poppinss/cliui/build/src/types';
2+
import { removeColorCodes } from '@code-pushup/test-utils';
3+
4+
export type LogLevel = Exclude<LoggingTypes, 'warning'> | 'warn';
5+
6+
const LOG_LEVELS = new Set<LogLevel>([
7+
'success',
8+
'error',
9+
'fatal',
10+
'info',
11+
'debug',
12+
'await',
13+
'warn',
14+
]);
15+
16+
type ExtractedMessage = {
17+
styledMessage: string;
18+
unstyledMessage: string;
19+
};
20+
21+
export function extractLevel(log: string): LogLevel | null {
22+
const match = removeColorCodes(log).match(/^\[\s*\w+\((?<level>\w+)\)\s*]/);
23+
const level = match?.groups?.['level'] as LogLevel | undefined;
24+
return level && LOG_LEVELS.has(level) ? level : null;
25+
}
26+
27+
export function extractMessage(log: string): ExtractedMessage {
28+
const match = log.match(
29+
/^\[\s*\w+\((?<level>\w+)\)\s*]\s*(?<message>.+?(\.\s*)?)$/,
30+
);
31+
const styledMessage = match?.groups?.['message'] ?? log;
32+
const unstyledMessage = removeColorCodes(styledMessage);
33+
return { styledMessage, unstyledMessage };
34+
}
35+
36+
export function hasExpectedMessage(
37+
expected: string,
38+
message: ExtractedMessage | undefined,
39+
): boolean {
40+
if (!message) {
41+
return false;
42+
}
43+
return (
44+
message.styledMessage === expected || message.unstyledMessage === expected
45+
);
46+
}
47+
48+
export function messageContains(
49+
expected: string,
50+
message: ExtractedMessage | undefined,
51+
): boolean {
52+
if (!message) {
53+
return false;
54+
}
55+
return (
56+
message.styledMessage.includes(expected) ||
57+
message.unstyledMessage.includes(expected)
58+
);
59+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
extractLevel,
4+
extractMessage,
5+
hasExpectedMessage,
6+
messageContains,
7+
} from './ui-logger.matcher.utils';
8+
9+
describe('extractLevel', () => {
10+
it('should extract level from an info log', () => {
11+
expect(extractLevel('[ blue(info) ] Info message')).toBe('info');
12+
});
13+
14+
it('should extract level from a warning log', () => {
15+
expect(extractLevel('[ yellow(warn) ] Warning message')).toBe('warn');
16+
});
17+
18+
it('should return null for a log without a level', () => {
19+
expect(extractLevel('Message without level')).toBeNull();
20+
});
21+
22+
it('should return null for an invalid log level', () => {
23+
expect(extractLevel('[ unknown ] Message with invalid level')).toBeNull();
24+
});
25+
});
26+
27+
describe('extractMessage', () => {
28+
it('should extract styled and unstyled messages from a log', () => {
29+
const { styledMessage, unstyledMessage } = extractMessage(
30+
'[ blue(info) ] \u001B[90mRun merge-diffs...\u001B[39m',
31+
);
32+
expect(styledMessage).toBe('\u001B[90mRun merge-diffs...\u001B[39m');
33+
expect(unstyledMessage).toBe('Run merge-diffs...');
34+
});
35+
36+
it('should handle logs without styling', () => {
37+
const { styledMessage, unstyledMessage } = extractMessage(
38+
'Warning message without styles.',
39+
);
40+
expect(styledMessage).toBe('Warning message without styles.');
41+
expect(unstyledMessage).toBe('Warning message without styles.');
42+
});
43+
44+
it('should return raw log for unmatchable logs', () => {
45+
const log = 'Unmatchable log format';
46+
const { styledMessage, unstyledMessage } = extractMessage(log);
47+
expect(styledMessage).toBe(log);
48+
expect(unstyledMessage).toBe(log);
49+
});
50+
});
51+
52+
describe('hasExpectedMessage', () => {
53+
it('should return true for a matching styled message', () => {
54+
const result = hasExpectedMessage('Styled message', {
55+
styledMessage: 'Styled message',
56+
unstyledMessage: 'Plain message',
57+
});
58+
expect(result).toBe(true);
59+
});
60+
61+
it('should return true for a matching unstyled message', () => {
62+
const result = hasExpectedMessage('Plain message', {
63+
styledMessage: 'Styled message',
64+
unstyledMessage: 'Plain message',
65+
});
66+
expect(result).toBe(true);
67+
});
68+
69+
it('should return false for a non-matching message', () => {
70+
const result = hasExpectedMessage('Non-matching message', {
71+
styledMessage: 'Styled message',
72+
unstyledMessage: 'Plain message',
73+
});
74+
expect(result).toBe(false);
75+
});
76+
77+
it('should return false for undefined message', () => {
78+
const result = hasExpectedMessage('Expected message', undefined);
79+
expect(result).toBe(false);
80+
});
81+
});
82+
83+
describe('messageContains', () => {
84+
it('should return true when styled message contains the substring', () => {
85+
expect(
86+
messageContains('message', {
87+
styledMessage: 'Styled message content',
88+
unstyledMessage: 'Plain message content',
89+
}),
90+
).toBe(true);
91+
});
92+
93+
it('should return true when unstyled message contains the substring', () => {
94+
expect(
95+
messageContains('Plain', {
96+
styledMessage: 'Styled message content',
97+
unstyledMessage: 'Plain message content',
98+
}),
99+
).toBe(true);
100+
});
101+
102+
it('should return false when neither message contains the substring', () => {
103+
expect(
104+
messageContains('Non-existent', {
105+
styledMessage: 'Styled message content',
106+
unstyledMessage: 'Plain message content',
107+
}),
108+
).toBe(false);
109+
});
110+
111+
it('should return false for undefined message', () => {
112+
expect(messageContains('Expected substring', undefined)).toBe(false);
113+
});
114+
});

testing/test-setup/src/vitest.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import type {
33
CustomAsymmetricPathMatchers,
44
CustomPathMatchers,
55
} from './lib/extend/path.matcher.js';
6+
import type { CustomUiLoggerMatchers } from './lib/extend/ui-logger.matcher.js';
67

78
declare module 'vitest' {
8-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
9-
interface Assertion extends CustomPathMatchers {}
9+
interface Assertion extends CustomPathMatchers, CustomUiLoggerMatchers {}
1010
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
1111
interface AsymmetricMatchersContaining extends CustomAsymmetricPathMatchers {}
1212
}

0 commit comments

Comments
 (0)