5
5
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6
6
*/
7
7
import * as path from 'node:path' ;
8
+ import { Readable } from 'node:stream' ;
9
+ import { pipeline } from 'node:stream/promises' ;
8
10
import { assert , expect , config } from 'chai' ;
9
11
import * as Sinon from 'sinon' ;
10
12
import { Lifecycle } from '@salesforce/core' ;
@@ -18,6 +20,7 @@ import {
18
20
} from '../../src/convert/replacements' ;
19
21
import { matchingContentFile } from '../mock' ;
20
22
import * as replacementsForMock from '../../src/convert/replacements' ;
23
+ const { ReplacementStream } = replacementsForMock ;
21
24
22
25
config . truncateThreshold = 0 ;
23
26
@@ -316,86 +319,159 @@ describe('executes replacements on a string', () => {
316
319
describe ( 'string' , ( ) => {
317
320
it ( 'basic replacement' , async ( ) => {
318
321
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
322
327
) . to . equal ( 'ThatIsATest' ) ;
323
328
} ) ;
324
329
it ( 'same replacement occuring multiple times' , async ( ) => {
325
330
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
329
336
) . to . equal ( 'ThatIsATestWithThatAndThat' ) ;
330
337
} ) ;
331
338
it ( 'multiple replacements' , async ( ) => {
332
339
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
337
346
) . to . equal ( 'ThatIsAnAwesomeTestWithThatAndThat' ) ;
338
347
} ) ;
339
348
} ) ;
340
349
describe ( 'regex' , ( ) => {
341
350
it ( 'basic replacement' , async ( ) => {
342
351
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
346
357
) . to . equal ( 'ThisIsNotATest' ) ;
347
358
} ) ;
348
359
it ( 'same replacement occuring multiple times' , async ( ) => {
349
360
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
353
366
) . to . equal ( 'ThiSISATeStWithThiSAndThiS' ) ;
354
367
} ) ;
355
368
it ( 'multiple replacements' , async ( ) => {
356
369
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
361
376
) . to . equal ( 'That Is A Test With This And Stuff' ) ;
362
377
} ) ;
363
378
} ) ;
364
379
365
380
describe ( 'warning when no replacement happened' , ( ) => {
366
381
let warnSpy : Sinon . SinonSpy ;
367
382
let emitSpy : Sinon . SinonSpy ;
383
+ const matchedFilename = 'foo' ;
368
384
369
385
beforeEach ( ( ) => {
370
- // everything is an emit. Warn calls emit, too.
371
386
warnSpy = Sinon . spy ( Lifecycle . getInstance ( ) , 'emitWarning' ) ;
372
387
emitSpy = Sinon . spy ( Lifecycle . getInstance ( ) , 'emit' ) ;
373
388
} ) ;
374
389
afterEach ( ( ) => {
375
390
warnSpy . restore ( ) ;
376
391
emitSpy . restore ( ) ;
377
392
} ) ;
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 ( [
380
396
{ toReplace : stringToRegex ( 'Nope' ) , replaceWith : 'Nah' , singleFile : true , matchedFilename } ,
381
397
] ) ;
398
+ await pipeline ( Readable . from ( [ 'ThisIsATest' ] ) , stream ) ;
382
399
expect ( warnSpy . callCount ) . to . equal ( 1 ) ;
383
- expect ( emitSpy . callCount ) . to . equal ( 1 ) ;
384
400
} ) ;
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 ( [
387
404
{ toReplace : stringToRegex ( 'Test' ) , replaceWith : 'SpyTest' , singleFile : true , matchedFilename } ,
388
405
] ) ;
406
+ await pipeline ( Readable . from ( [ 'ThisIsATest' ] ) , stream ) ;
389
407
expect ( warnSpy . callCount ) . to . equal ( 0 ) ;
390
- // because it emits the replacement event
391
- expect ( emitSpy . callCount ) . to . equal ( 1 ) ;
392
408
} ) ;
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 ( [
395
412
{ toReplace : stringToRegex ( 'Nope' ) , replaceWith : 'Nah' , singleFile : false , matchedFilename } ,
396
413
] ) ;
414
+ await pipeline ( Readable . from ( [ 'ThisIsATest' ] ) , stream ) ;
397
415
expect ( warnSpy . callCount ) . to . equal ( 0 ) ;
398
- expect ( emitSpy . callCount ) . to . equal ( 0 ) ;
399
416
} ) ;
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 ( ) ;
400
476
} ) ;
401
477
} ) ;
0 commit comments