From 45936158032f7876de7c0a35c06b4a0041731eae Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:14:22 +0800 Subject: [PATCH 01/10] feat: add expectLogTimes helper --- README.md | 3 +++ src/proxyConsole.ts | 21 ++++++++++++++++++ tests/proxyConsole.test.ts | 44 +++++++++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1370b73..b4dbac4 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ try { console.error('Something failed'); await logHelper.expectLog('Something happened'); + logHelper.expectLogTimes('Something happened', 1); logHelper.expectNoLog('Unexpected error'); } finally { logHelper.restore(); @@ -141,6 +142,8 @@ try { console.log(logHelper.logs); ``` +`expectLogTimes(pattern, times)` synchronously asserts the number of non-overlapping matches in the captured output. It accepts a literal string or a regular expression and joins output chunks before counting, so repeated messages in one chunk and messages split across chunks are counted correctly. It does not wait for future output. Call `clearLogs()` to reset the captured output before checking another build. + ### normalizeEol Normalizes CRLF line endings to LF. diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index befe907..5ca53b7 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -112,6 +112,26 @@ export const createLogHelper = () => { } }; + /** Assert the number of non-overlapping matches in the captured output. */ + const expectLogTimes = (pattern: string | RegExp, times: number) => { + const regexp = + typeof pattern === 'string' + ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') + : new RegExp( + pattern.source, + pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, + ); + const actualTimes = Array.from(logs.join('').matchAll(regexp)).length; + + if (actualTimes !== times) { + const title = styleText(['bold', 'red'], 'Unexpected log count.'); + const expected = styleText('yellow', pattern.toString()); + throw new Error( + `${title}\nPattern: ${expected}\nExpected: ${times}\nReceived: ${actualTimes}\nGet:\n${originalLogs.join('\n')}`, + ); + } + }; + return { logs, originalLogs, @@ -119,6 +139,7 @@ export const createLogHelper = () => { clearLogs, expectLog, expectNoLog, + expectLogTimes, }; }; diff --git a/tests/proxyConsole.test.ts b/tests/proxyConsole.test.ts index 44ad928..fa52c85 100644 --- a/tests/proxyConsole.test.ts +++ b/tests/proxyConsole.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'rstack/test'; -import { proxyConsole } from '../src/index'; +import { createLogHelper, proxyConsole } from '../src/index'; test('should capture console output and expose the Rsbuild log helpers', async () => { const originalLog = console.log; @@ -18,6 +18,7 @@ test('should capture console output and expose the Rsbuild log helpers', async ( 'second log', ]); expect(() => logHelper.expectNoLog('missing log')).not.toThrow(); + logHelper.expectLogTimes('second log', 1); logHelper.clearLogs(); expect(logHelper.logs).toEqual([]); @@ -45,3 +46,44 @@ test('should support strict and POSIX log matching', async () => { logHelper.restore(); } }); + +test('should count log occurrences across output chunks', () => { + const logHelper = createLogHelper(); + const message = 'watching for changes...'; + + logHelper.expectLogTimes(message, 0); + logHelper.addLog(`${message}\n${message}\nwatching for `); + logHelper.addLog('changes...\n'); + logHelper.expectLogTimes(message, 3); + + expect(() => logHelper.expectLogTimes(message, 1)).toThrow( + 'Expected: 1\nReceived: 3', + ); + + logHelper.clearLogs(); + logHelper.expectLogTimes(message, 0); + logHelper.addLog(message); + logHelper.expectLogTimes(message, 1); +}); + +test('should count strings literally and ignore ANSI control characters', () => { + const logHelper = createLogHelper(); + const message = 'ready [web] (entry.js) .*+?^${}|\\'; + + logHelper.addLog(`\u001B[32m${message}\u001B[39m\n${message}`); + logHelper.expectLogTimes(message, 2); + logHelper.expectLogTimes('missing', 0); +}); + +test('should preserve regular expression flags and lastIndex when counting', () => { + const logHelper = createLogHelper(); + logHelper.addLog('READY\nready\nnot ready\n'); + + logHelper.expectLogTimes(/^ready$/im, 2); + const pattern = /ready/gi; + pattern.lastIndex = 6; + + logHelper.expectLogTimes(pattern, 3); + logHelper.expectLogTimes(pattern, 3); + expect(pattern.lastIndex).toBe(6); +}); From 46ad21c9f914b9d5194cbddc1d7d540a6649bcd0 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:16:12 +0800 Subject: [PATCH 02/10] refactor: simplify log occurrence counting --- src/proxyConsole.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index 5ca53b7..efffde4 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -121,7 +121,7 @@ export const createLogHelper = () => { pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, ); - const actualTimes = Array.from(logs.join('').matchAll(regexp)).length; + const actualTimes = logs.join('').match(regexp)?.length ?? 0; if (actualTimes !== times) { const title = styleText(['bold', 'red'], 'Unexpected log count.'); From eb02efbecf2b1cc0de95f1904bc82c5455cdf4b5 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:22:42 +0800 Subject: [PATCH 03/10] refactor: count literal log matches with indexOf --- src/proxyConsole.ts | 28 ++++++++++++++++++++-------- tests/proxyConsole.test.ts | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index efffde4..1a06128 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -114,14 +114,26 @@ export const createLogHelper = () => { /** Assert the number of non-overlapping matches in the captured output. */ const expectLogTimes = (pattern: string | RegExp, times: number) => { - const regexp = - typeof pattern === 'string' - ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') - : new RegExp( - pattern.source, - pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, - ); - const actualTimes = logs.join('').match(regexp)?.length ?? 0; + const output = logs.join(''); + let actualTimes = 0; + + if (typeof pattern === 'string') { + let position = 0; + while (position <= output.length) { + position = output.indexOf(pattern, position); + if (position === -1) { + break; + } + actualTimes++; + position += pattern.length || 1; + } + } else { + const regexp = new RegExp( + pattern.source, + pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, + ); + actualTimes = output.match(regexp)?.length ?? 0; + } if (actualTimes !== times) { const title = styleText(['bold', 'red'], 'Unexpected log count.'); diff --git a/tests/proxyConsole.test.ts b/tests/proxyConsole.test.ts index fa52c85..0eb6f87 100644 --- a/tests/proxyConsole.test.ts +++ b/tests/proxyConsole.test.ts @@ -75,6 +75,22 @@ test('should count strings literally and ignore ANSI control characters', () => logHelper.expectLogTimes('missing', 0); }); +test('should count non-overlapping string matches', () => { + const logHelper = createLogHelper(); + logHelper.addLog('aaaaa'); + + logHelper.expectLogTimes('aa', 2); + logHelper.expectLogTimes('aaa', 1); +}); + +test('should count empty strings at each position without looping forever', () => { + const logHelper = createLogHelper(); + logHelper.expectLogTimes('', 1); + + logHelper.addLog('abc'); + logHelper.expectLogTimes('', 4); +}); + test('should preserve regular expression flags and lastIndex when counting', () => { const logHelper = createLogHelper(); logHelper.addLog('READY\nready\nnot ready\n'); From 7f67dddfc6a057714f500dee5bf625b69c84b773 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:46:41 +0800 Subject: [PATCH 04/10] refactor: unify log counting with regular expressions --- src/proxyConsole.ts | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index 1a06128..efffde4 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -114,26 +114,14 @@ export const createLogHelper = () => { /** Assert the number of non-overlapping matches in the captured output. */ const expectLogTimes = (pattern: string | RegExp, times: number) => { - const output = logs.join(''); - let actualTimes = 0; - - if (typeof pattern === 'string') { - let position = 0; - while (position <= output.length) { - position = output.indexOf(pattern, position); - if (position === -1) { - break; - } - actualTimes++; - position += pattern.length || 1; - } - } else { - const regexp = new RegExp( - pattern.source, - pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, - ); - actualTimes = output.match(regexp)?.length ?? 0; - } + const regexp = + typeof pattern === 'string' + ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') + : new RegExp( + pattern.source, + pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, + ); + const actualTimes = logs.join('').match(regexp)?.length ?? 0; if (actualTimes !== times) { const title = styleText(['bold', 'red'], 'Unexpected log count.'); From 40124d19fde171a4d8bc076c8555965edaed72e6 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:52:38 +0800 Subject: [PATCH 05/10] refactor: use indexOf for literal log matching --- src/proxyConsole.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index efffde4..1a06128 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -114,14 +114,26 @@ export const createLogHelper = () => { /** Assert the number of non-overlapping matches in the captured output. */ const expectLogTimes = (pattern: string | RegExp, times: number) => { - const regexp = - typeof pattern === 'string' - ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g') - : new RegExp( - pattern.source, - pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, - ); - const actualTimes = logs.join('').match(regexp)?.length ?? 0; + const output = logs.join(''); + let actualTimes = 0; + + if (typeof pattern === 'string') { + let position = 0; + while (position <= output.length) { + position = output.indexOf(pattern, position); + if (position === -1) { + break; + } + actualTimes++; + position += pattern.length || 1; + } + } else { + const regexp = new RegExp( + pattern.source, + pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`, + ); + actualTimes = output.match(regexp)?.length ?? 0; + } if (actualTimes !== times) { const title = styleText(['bold', 'red'], 'Unexpected log count.'); From 13d6592aeb89f6e54e3a0ac47c0c8f80185c5043 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:55:40 +0800 Subject: [PATCH 06/10] fix: strip ANSI after joining log chunks --- src/proxyConsole.ts | 4 +++- tests/proxyConsole.test.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index 1a06128..c9a4c0a 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -38,6 +38,7 @@ const matchPattern = ( export const createLogHelper = () => { const logs: string[] = []; const originalLogs: string[] = []; + let logStartIndex = 0; const logPatterns = new Set<{ pattern: LogPattern; @@ -47,6 +48,7 @@ export const createLogHelper = () => { const clearLogs = () => { logs.splice(0); + logStartIndex = originalLogs.length; }; const addLog = (input: string) => { @@ -114,7 +116,7 @@ export const createLogHelper = () => { /** Assert the number of non-overlapping matches in the captured output. */ const expectLogTimes = (pattern: string | RegExp, times: number) => { - const output = logs.join(''); + const output = stripAnsi(originalLogs.slice(logStartIndex).join('')); let actualTimes = 0; if (typeof pattern === 'string') { diff --git a/tests/proxyConsole.test.ts b/tests/proxyConsole.test.ts index 0eb6f87..3fd18b9 100644 --- a/tests/proxyConsole.test.ts +++ b/tests/proxyConsole.test.ts @@ -75,6 +75,23 @@ test('should count strings literally and ignore ANSI control characters', () => logHelper.expectLogTimes('missing', 0); }); +test('should strip ANSI sequences after joining output chunks', () => { + const logHelper = createLogHelper(); + const message = 'watching for changes...'; + + logHelper.addLog('watching for \u001B[3'); + logHelper.addLog('9mchanges...\n'); + logHelper.expectLogTimes(message, 1); + logHelper.expectLogTimes(/watching for changes\.\.\./, 1); + + logHelper.clearLogs(); + logHelper.expectLogTimes(message, 0); + logHelper.addLog('watching for \u001B[3'); + logHelper.addLog('9mchanges...\n'); + logHelper.expectLogTimes(message, 1); + expect(logHelper.originalLogs).toHaveLength(4); +}); + test('should count non-overlapping string matches', () => { const logHelper = createLogHelper(); logHelper.addLog('aaaaa'); From fa51cd00b001440c845241b270b204bdda50b8b8 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:58:21 +0800 Subject: [PATCH 07/10] test: simplify log counting coverage --- tests/proxyConsole.test.ts | 45 ++++---------------------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/tests/proxyConsole.test.ts b/tests/proxyConsole.test.ts index 3fd18b9..689c128 100644 --- a/tests/proxyConsole.test.ts +++ b/tests/proxyConsole.test.ts @@ -18,7 +18,6 @@ test('should capture console output and expose the Rsbuild log helpers', async ( 'second log', ]); expect(() => logHelper.expectNoLog('missing log')).not.toThrow(); - logHelper.expectLogTimes('second log', 1); logHelper.clearLogs(); expect(logHelper.logs).toEqual([]); @@ -51,10 +50,10 @@ test('should count log occurrences across output chunks', () => { const logHelper = createLogHelper(); const message = 'watching for changes...'; - logHelper.expectLogTimes(message, 0); logHelper.addLog(`${message}\n${message}\nwatching for `); logHelper.addLog('changes...\n'); logHelper.expectLogTimes(message, 3); + logHelper.expectLogTimes(/watching for changes\.\.\./, 3); expect(() => logHelper.expectLogTimes(message, 1)).toThrow( 'Expected: 1\nReceived: 3', @@ -66,57 +65,21 @@ test('should count log occurrences across output chunks', () => { logHelper.expectLogTimes(message, 1); }); -test('should count strings literally and ignore ANSI control characters', () => { - const logHelper = createLogHelper(); - const message = 'ready [web] (entry.js) .*+?^${}|\\'; - - logHelper.addLog(`\u001B[32m${message}\u001B[39m\n${message}`); - logHelper.expectLogTimes(message, 2); - logHelper.expectLogTimes('missing', 0); -}); - test('should strip ANSI sequences after joining output chunks', () => { const logHelper = createLogHelper(); - const message = 'watching for changes...'; logHelper.addLog('watching for \u001B[3'); logHelper.addLog('9mchanges...\n'); - logHelper.expectLogTimes(message, 1); - logHelper.expectLogTimes(/watching for changes\.\.\./, 1); - - logHelper.clearLogs(); - logHelper.expectLogTimes(message, 0); - logHelper.addLog('watching for \u001B[3'); - logHelper.addLog('9mchanges...\n'); - logHelper.expectLogTimes(message, 1); - expect(logHelper.originalLogs).toHaveLength(4); -}); - -test('should count non-overlapping string matches', () => { - const logHelper = createLogHelper(); - logHelper.addLog('aaaaa'); - - logHelper.expectLogTimes('aa', 2); - logHelper.expectLogTimes('aaa', 1); -}); - -test('should count empty strings at each position without looping forever', () => { - const logHelper = createLogHelper(); - logHelper.expectLogTimes('', 1); - - logHelper.addLog('abc'); - logHelper.expectLogTimes('', 4); + logHelper.expectLogTimes('watching for changes...', 1); }); test('should preserve regular expression flags and lastIndex when counting', () => { const logHelper = createLogHelper(); - logHelper.addLog('READY\nready\nnot ready\n'); + logHelper.addLog('READY\nready\n'); - logHelper.expectLogTimes(/^ready$/im, 2); const pattern = /ready/gi; pattern.lastIndex = 6; - logHelper.expectLogTimes(pattern, 3); - logHelper.expectLogTimes(pattern, 3); + logHelper.expectLogTimes(pattern, 2); expect(pattern.lastIndex).toBe(6); }); From 70327d125fe5fbffafeb0b2b4007e238d64a08a5 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 08:59:10 +0800 Subject: [PATCH 08/10] docs: simplify expectLogTimes description --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b4dbac4..8e361fa 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ try { console.log(logHelper.logs); ``` -`expectLogTimes(pattern, times)` synchronously asserts the number of non-overlapping matches in the captured output. It accepts a literal string or a regular expression and joins output chunks before counting, so repeated messages in one chunk and messages split across chunks are counted correctly. It does not wait for future output. Call `clearLogs()` to reset the captured output before checking another build. +`expectLogTimes(pattern, times)` synchronously asserts how many times a string or regular expression matches the captured output. ### normalizeEol From 488adfe009cab9f99130ee8a83d50a013b68ebb9 Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 09:00:28 +0800 Subject: [PATCH 09/10] test: remove regex state counting case --- tests/proxyConsole.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/tests/proxyConsole.test.ts b/tests/proxyConsole.test.ts index 689c128..e586edb 100644 --- a/tests/proxyConsole.test.ts +++ b/tests/proxyConsole.test.ts @@ -72,14 +72,3 @@ test('should strip ANSI sequences after joining output chunks', () => { logHelper.addLog('9mchanges...\n'); logHelper.expectLogTimes('watching for changes...', 1); }); - -test('should preserve regular expression flags and lastIndex when counting', () => { - const logHelper = createLogHelper(); - logHelper.addLog('READY\nready\n'); - - const pattern = /ready/gi; - pattern.lastIndex = 6; - - logHelper.expectLogTimes(pattern, 2); - expect(pattern.lastIndex).toBe(6); -}); From 457c91ba8b626e1ef6710435abb43985149beb1a Mon Sep 17 00:00:00 2001 From: neverland Date: Sat, 5 Sep 2026 09:12:12 +0800 Subject: [PATCH 10/10] fix: preserve console boundaries when counting logs --- src/proxyConsole.ts | 11 ++++++----- tests/proxyConsole.test.ts | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/proxyConsole.ts b/src/proxyConsole.ts index c9a4c0a..3a47396 100644 --- a/src/proxyConsole.ts +++ b/src/proxyConsole.ts @@ -38,7 +38,7 @@ const matchPattern = ( export const createLogHelper = () => { const logs: string[] = []; const originalLogs: string[] = []; - let logStartIndex = 0; + let rawOutput = ''; const logPatterns = new Set<{ pattern: LogPattern; @@ -48,13 +48,14 @@ export const createLogHelper = () => { const clearLogs = () => { logs.splice(0); - logStartIndex = originalLogs.length; + rawOutput = ''; }; - const addLog = (input: string) => { + const addLog = (input: string, options?: { newline?: boolean }) => { const log = stripAnsi(input); logs.push(log); originalLogs.push(input); + rawOutput += options?.newline ? `${input}\n` : input; for (const { pattern, resolve, options } of logPatterns) { if (matchPattern(log, pattern, options)) { @@ -116,7 +117,7 @@ export const createLogHelper = () => { /** Assert the number of non-overlapping matches in the captured output. */ const expectLogTimes = (pattern: string | RegExp, times: number) => { - const output = stripAnsi(originalLogs.slice(logStartIndex).join('')); + const output = stripAnsi(rawOutput); let actualTimes = 0; if (typeof pattern === 'string') { @@ -191,7 +192,7 @@ export const proxyConsole = ({ return typeof arg === 'object' ? JSON.stringify(arg) : String(arg); }) .join(' '); - logHelper.addLog(logMessage); + logHelper.addLog(logMessage, { newline: true }); }; } diff --git a/tests/proxyConsole.test.ts b/tests/proxyConsole.test.ts index e586edb..32e1045 100644 --- a/tests/proxyConsole.test.ts +++ b/tests/proxyConsole.test.ts @@ -18,6 +18,8 @@ test('should capture console output and expose the Rsbuild log helpers', async ( 'second log', ]); expect(() => logHelper.expectNoLog('missing log')).not.toThrow(); + logHelper.expectLogTimes('{"value":1}second log', 0); + logHelper.expectLogTimes(/^second log$/m, 1); logHelper.clearLogs(); expect(logHelper.logs).toEqual([]);