55 * For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66 */
77import * as path from 'node:path' ;
8+ import { Readable } from 'node:stream' ;
9+ import { pipeline } from 'node:stream/promises' ;
810import { assert , expect , config } from 'chai' ;
911import * as Sinon from 'sinon' ;
1012import { Lifecycle } from '@salesforce/core' ;
@@ -18,6 +20,7 @@ import {
1820} from '../../src/convert/replacements' ;
1921import { matchingContentFile } from '../mock' ;
2022import * as replacementsForMock from '../../src/convert/replacements' ;
23+ const { ReplacementStream } = replacementsForMock ;
2124
2225config . truncateThreshold = 0 ;
2326
@@ -316,86 +319,159 @@ describe('executes replacements on a string', () => {
316319 describe ( 'string' , ( ) => {
317320 it ( 'basic replacement' , async ( ) => {
318321 expect (
319- await replacementIterations ( 'ThisIsATest' , [
320- { matchedFilename, toReplace : stringToRegex ( 'This' ) , replaceWith : 'That' , singleFile : true } ,
321- ] )
322+ (
323+ await replacementIterations ( 'ThisIsATest' , [
324+ { matchedFilename, toReplace : stringToRegex ( 'This' ) , replaceWith : 'That' , singleFile : true } ,
325+ ] )
326+ ) . output
322327 ) . to . equal ( 'ThatIsATest' ) ;
323328 } ) ;
324329 it ( 'same replacement occuring multiple times' , async ( ) => {
325330 expect (
326- await replacementIterations ( 'ThisIsATestWithThisAndThis' , [
327- { matchedFilename, toReplace : stringToRegex ( 'This' ) , replaceWith : 'That' , singleFile : true } ,
328- ] )
331+ (
332+ await replacementIterations ( 'ThisIsATestWithThisAndThis' , [
333+ { matchedFilename, toReplace : stringToRegex ( 'This' ) , replaceWith : 'That' , singleFile : true } ,
334+ ] )
335+ ) . output
329336 ) . to . equal ( 'ThatIsATestWithThatAndThat' ) ;
330337 } ) ;
331338 it ( 'multiple replacements' , async ( ) => {
332339 expect (
333- await replacementIterations ( 'ThisIsATestWithThisAndThis' , [
334- { matchedFilename, toReplace : stringToRegex ( 'This' ) , replaceWith : 'That' } ,
335- { matchedFilename, toReplace : stringToRegex ( 'ATest' ) , replaceWith : 'AnAwesomeTest' } ,
336- ] )
340+ (
341+ await replacementIterations ( 'ThisIsATestWithThisAndThis' , [
342+ { matchedFilename, toReplace : stringToRegex ( 'This' ) , replaceWith : 'That' } ,
343+ { matchedFilename, toReplace : stringToRegex ( 'ATest' ) , replaceWith : 'AnAwesomeTest' } ,
344+ ] )
345+ ) . output
337346 ) . to . equal ( 'ThatIsAnAwesomeTestWithThatAndThat' ) ;
338347 } ) ;
339348 } ) ;
340349 describe ( 'regex' , ( ) => {
341350 it ( 'basic replacement' , async ( ) => {
342351 expect (
343- await replacementIterations ( 'ThisIsATest' , [
344- { toReplace : / I s / g, replaceWith : 'IsNot' , singleFile : true , matchedFilename } ,
345- ] )
352+ (
353+ await replacementIterations ( 'ThisIsATest' , [
354+ { toReplace : / I s / g, replaceWith : 'IsNot' , singleFile : true , matchedFilename } ,
355+ ] )
356+ ) . output
346357 ) . to . equal ( 'ThisIsNotATest' ) ;
347358 } ) ;
348359 it ( 'same replacement occuring multiple times' , async ( ) => {
349360 expect (
350- await replacementIterations ( 'ThisIsATestWithThisAndThis' , [
351- { toReplace : / s / g, replaceWith : 'S' , singleFile : true , matchedFilename } ,
352- ] )
361+ (
362+ await replacementIterations ( 'ThisIsATestWithThisAndThis' , [
363+ { toReplace : / s / g, replaceWith : 'S' , singleFile : true , matchedFilename } ,
364+ ] )
365+ ) . output
353366 ) . to . equal ( 'ThiSISATeStWithThiSAndThiS' ) ;
354367 } ) ;
355368 it ( 'multiple replacements' , async ( ) => {
356369 expect (
357- await replacementIterations ( 'This Is A Test With This And This' , [
358- { toReplace : / ^ T .{ 2 } s / , replaceWith : 'That' , singleFile : false , matchedFilename } ,
359- { toReplace : / T .{ 2 } s $ / , replaceWith : 'Stuff' , singleFile : false , matchedFilename } ,
360- ] )
370+ (
371+ await replacementIterations ( 'This Is A Test With This And This' , [
372+ { toReplace : / ^ T .{ 2 } s / , replaceWith : 'That' , singleFile : false , matchedFilename } ,
373+ { toReplace : / T .{ 2 } s $ / , replaceWith : 'Stuff' , singleFile : false , matchedFilename } ,
374+ ] )
375+ ) . output
361376 ) . to . equal ( 'That Is A Test With This And Stuff' ) ;
362377 } ) ;
363378 } ) ;
364379
365380 describe ( 'warning when no replacement happened' , ( ) => {
366381 let warnSpy : Sinon . SinonSpy ;
367382 let emitSpy : Sinon . SinonSpy ;
383+ const matchedFilename = 'foo' ;
368384
369385 beforeEach ( ( ) => {
370- // everything is an emit. Warn calls emit, too.
371386 warnSpy = Sinon . spy ( Lifecycle . getInstance ( ) , 'emitWarning' ) ;
372387 emitSpy = Sinon . spy ( Lifecycle . getInstance ( ) , 'emit' ) ;
373388 } ) ;
374389 afterEach ( ( ) => {
375390 warnSpy . restore ( ) ;
376391 emitSpy . restore ( ) ;
377392 } ) ;
378- it ( 'emits warning only when no change' , async ( ) => {
379- await replacementIterations ( 'ThisIsATest' , [
393+
394+ it ( 'emits warning only when no change in any chunk' , async ( ) => {
395+ const stream = new ReplacementStream ( [
380396 { toReplace : stringToRegex ( 'Nope' ) , replaceWith : 'Nah' , singleFile : true , matchedFilename } ,
381397 ] ) ;
398+ await pipeline ( Readable . from ( [ 'ThisIsATest' ] ) , stream ) ;
382399 expect ( warnSpy . callCount ) . to . equal ( 1 ) ;
383- expect ( emitSpy . callCount ) . to . equal ( 1 ) ;
384400 } ) ;
385- it ( 'no warning when string is replaced' , async ( ) => {
386- await replacementIterations ( 'ThisIsATest' , [
401+
402+ it ( 'does not emit warning when string is replaced in any chunk' , async ( ) => {
403+ const stream = new ReplacementStream ( [
387404 { toReplace : stringToRegex ( 'Test' ) , replaceWith : 'SpyTest' , singleFile : true , matchedFilename } ,
388405 ] ) ;
406+ await pipeline ( Readable . from ( [ 'ThisIsATest' ] ) , stream ) ;
389407 expect ( warnSpy . callCount ) . to . equal ( 0 ) ;
390- // because it emits the replacement event
391- expect ( emitSpy . callCount ) . to . equal ( 1 ) ;
392408 } ) ;
393- it ( 'no warning when no replacement but not a single file (ex: glob)' , async ( ) => {
394- await replacementIterations ( 'ThisIsATest' , [
409+
410+ it ( 'does not emit warning for non-singleFile replacements' , async ( ) => {
411+ const stream = new ReplacementStream ( [
395412 { toReplace : stringToRegex ( 'Nope' ) , replaceWith : 'Nah' , singleFile : false , matchedFilename } ,
396413 ] ) ;
414+ await pipeline ( Readable . from ( [ 'ThisIsATest' ] ) , stream ) ;
397415 expect ( warnSpy . callCount ) . to . equal ( 0 ) ;
398- expect ( emitSpy . callCount ) . to . equal ( 0 ) ;
399416 } ) ;
417+
418+ it ( 'emits warning only once for multiple chunks with no match' , async ( ) => {
419+ const stream = new ReplacementStream ( [
420+ { toReplace : stringToRegex ( 'Nope' ) , replaceWith : 'Nah' , singleFile : true , matchedFilename } ,
421+ ] ) ;
422+ await pipeline ( Readable . from ( [ 'ThisIsA' , 'Test' ] ) , stream ) ;
423+ expect ( warnSpy . callCount ) . to . equal ( 1 ) ;
424+ } ) ;
425+
426+ it ( 'does not emit warning if match is found in any chunk' , async ( ) => {
427+ const stream = new ReplacementStream ( [
428+ { toReplace : stringToRegex ( 'Test' ) , replaceWith : 'SpyTest' , singleFile : true , matchedFilename } ,
429+ ] ) ;
430+ await pipeline ( Readable . from ( [ 'ThisIsA' , 'Test' ] ) , stream ) ;
431+ expect ( warnSpy . callCount ) . to . equal ( 0 ) ;
432+ } ) ;
433+ } ) ;
434+
435+ it ( 'performs replacements across chunk boundaries without warnings' , async ( ) => {
436+ const chunkSize = 16 * 1024 ; // 16KB
437+ // Create a large string with two replacement targets, one at the start, one at the end
438+ const before = 'REPLACE_ME_1' ;
439+ const after = 'REPLACE_ME_2' ;
440+ const middle = 'A' . repeat ( chunkSize * 2 - before . length - after . length ) ; // ensure > 2 chunks
441+ const bigText = before + middle + after ;
442+ const expected = 'DONE_1' + middle + 'DONE_2' ;
443+ const stream = new ReplacementStream ( [
444+ { toReplace : / R E P L A C E _ M E _ 1 / g, replaceWith : 'DONE_1' , singleFile : true , matchedFilename : 'bigfile.txt' } ,
445+ { toReplace : / R E P L A C E _ M E _ 2 / g, replaceWith : 'DONE_2' , singleFile : true , matchedFilename : 'bigfile.txt' } ,
446+ ] ) ;
447+ const warnSpy = Sinon . spy ( Lifecycle . getInstance ( ) , 'emitWarning' ) ;
448+ let result = '' ;
449+ stream . on ( 'data' , ( chunk ) => {
450+ result += chunk . toString ( ) ;
451+ } ) ;
452+ // Node.js Readable.from([bigText]) emits the entire string as a single chunk, regardless of its size.
453+ // To simulate real-world chunking (like fs.createReadStream does for large files), we define a custom
454+ // Readable that splits the input string into smaller chunks. This allows us to test chunk boundary behavior.
455+ class ChunkedReadable extends Readable {
456+ private pos = 0 ;
457+
458+ public constructor ( private text : string , private chunkLen : number ) {
459+ super ( ) ;
460+ }
461+ public _read ( ) {
462+ if ( this . pos >= this . text . length ) {
463+ this . push ( null ) ;
464+ return ;
465+ }
466+ const end = Math . min ( this . pos + this . chunkLen , this . text . length ) ;
467+ this . push ( this . text . slice ( this . pos , end ) ) ;
468+ this . pos = end ;
469+ }
470+ }
471+ // Use ChunkedReadable to simulate chunked input
472+ await pipeline ( new ChunkedReadable ( bigText , chunkSize ) , stream ) ;
473+ expect ( result ) . to . equal ( expected ) ;
474+ expect ( warnSpy . callCount ) . to . equal ( 0 ) ;
475+ warnSpy . restore ( ) ;
400476 } ) ;
401477} ) ;
0 commit comments