Skip to content

Commit 7a65b44

Browse files
authored
fix: track string replacements W-18794860 (#1577)
* fix: track string replacements * chore: update tests * chore: new tests * chore: final test
1 parent 4fd97d2 commit 7a65b44

File tree

3 files changed

+143
-48
lines changed

3 files changed

+143
-48
lines changed

src/convert/replacements.ts

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,16 @@ export const getReplacementStreamForReadable = (
4040

4141
/**
4242
* A stream for replacing the contents of a single SourceComponent.
43-
*
43+
* Tracks which replacements were found across all chunks and emits warnings only at the end.
4444
*/
45-
class ReplacementStream extends Transform {
45+
export class ReplacementStream extends Transform {
46+
private readonly foundReplacements = new Set<string>();
47+
private readonly allReplacements: MarkedReplacement[];
48+
private readonly lifecycleInstance = Lifecycle.getInstance();
49+
4650
public constructor(private readonly replacements: MarkedReplacement[]) {
4751
super({ objectMode: true });
52+
this.allReplacements = replacements;
4853
}
4954

5055
public async _transform(
@@ -53,42 +58,56 @@ class ReplacementStream extends Transform {
5358
callback: (error?: Error, data?: Buffer) => void
5459
): Promise<void> {
5560
let error: Error | undefined;
56-
// read and do the various replacements
57-
callback(error, Buffer.from(await replacementIterations(chunk.toString(), this.replacements)));
61+
const { output, found } = await replacementIterations(chunk.toString(), this.replacements);
62+
for (const foundKey of found) {
63+
this.foundReplacements.add(foundKey);
64+
}
65+
callback(error, Buffer.from(output));
66+
}
67+
68+
public async _flush(callback: (error?: Error) => void): Promise<void> {
69+
// At the end of the stream, emit warnings for replacements not found
70+
for (const replacement of this.allReplacements) {
71+
const key = replacement.toReplace.toString();
72+
if (replacement.singleFile && !this.foundReplacements.has(key)) {
73+
// eslint-disable-next-line no-await-in-loop
74+
await this.lifecycleInstance.emitWarning(
75+
`Your sfdx-project.json specifies that ${key} should be replaced in ${replacement.matchedFilename}, but it was not found.`
76+
);
77+
}
78+
}
79+
callback();
5880
}
5981
}
6082

6183
/**
6284
* perform an array of replacements on a string
63-
* emits warnings when an expected replacement target isn't found
85+
* returns both the replaced string and a Set of found replacements
6486
*/
65-
export const replacementIterations = async (input: string, replacements: MarkedReplacement[]): Promise<string> => {
87+
export const replacementIterations = async (
88+
input: string,
89+
replacements: MarkedReplacement[]
90+
): Promise<{ output: string; found: Set<string> }> => {
6691
const lifecycleInstance = Lifecycle.getInstance();
6792
let output = input;
93+
const found = new Set<string>();
6894
for (const replacement of replacements) {
69-
// TODO: node 16+ has String.replaceAll for non-regex scenarios
7095
const regex =
7196
typeof replacement.toReplace === 'string' ? new RegExp(replacement.toReplace, 'g') : replacement.toReplace;
7297
const replaced = output.replace(regex, replacement.replaceWith ?? '');
7398

7499
if (replaced !== output) {
75100
output = replaced;
101+
found.add(replacement.toReplace.toString());
76102
// eslint-disable-next-line no-await-in-loop
77103
await lifecycleInstance.emit('replacement', {
78104
filename: replacement.matchedFilename,
79105
replaced: replacement.toReplace.toString(),
80106
} as ReplacementEvent);
81-
} else if (replacement.singleFile) {
82-
// replacements need to be done sequentially
83-
// eslint-disable-next-line no-await-in-loop
84-
await lifecycleInstance.emitWarning(
85-
`Your sfdx-project.json specifies that ${replacement.toReplace.toString()} should be replaced in ${
86-
replacement.matchedFilename
87-
}, but it was not found.`
88-
);
89107
}
108+
// No warning here; warnings are handled in ReplacementStream._flush
90109
}
91-
return output;
110+
return { output, found };
92111
};
93112

94113
/**

src/resolve/sourceComponent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ export class SourceComponent implements MetadataComponent {
198198

199199
const replacements = this.replacements?.[xml] ?? this.parent?.replacements?.[xml];
200200
return this.parseAndValidateXML<T>(
201-
replacements ? await replacementIterations(contents, replacements) : contents,
201+
replacements ? (await replacementIterations(contents, replacements)).output : contents,
202202
xml
203203
);
204204
}

test/convert/replacements.test.ts

Lines changed: 107 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
66
*/
77
import * as path from 'node:path';
8+
import { Readable } from 'node:stream';
9+
import { pipeline } from 'node:stream/promises';
810
import { assert, expect, config } from 'chai';
911
import * as Sinon from 'sinon';
1012
import { Lifecycle } from '@salesforce/core';
@@ -18,6 +20,7 @@ import {
1820
} from '../../src/convert/replacements';
1921
import { matchingContentFile } from '../mock';
2022
import * as replacementsForMock from '../../src/convert/replacements';
23+
const { ReplacementStream } = replacementsForMock;
2124

2225
config.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: /Is/g, replaceWith: 'IsNot', singleFile: true, matchedFilename },
345-
])
352+
(
353+
await replacementIterations('ThisIsATest', [
354+
{ toReplace: /Is/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: /REPLACE_ME_1/g, replaceWith: 'DONE_1', singleFile: true, matchedFilename: 'bigfile.txt' },
445+
{ toReplace: /REPLACE_ME_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

Comments
 (0)