Skip to content

Commit 0b3e75f

Browse files
authored
feat: absolute paths for string replacements for out-of-project (#1239)
* feat: absolute paths for string replacements for out-of-project * test: refactor zip extraction * feat: componentSetBuilder sets projectDirectory * fix: tests use relative paths for file replacements, tighter customLabels exception * fix: registry bug for affinityScoreDefinition * refactor: variable rename for readability * test: more ut for whenEnv and absolute files
1 parent 88c4bc5 commit 0b3e75f

File tree

9 files changed

+1266
-2135
lines changed

9 files changed

+1266
-2135
lines changed

CHANGELOG.md

Lines changed: 376 additions & 1426 deletions
Large diffs are not rendered by default.

METADATA_SUPPORT.md

Lines changed: 615 additions & 617 deletions
Large diffs are not rendered by default.

src/collections/componentSetBuilder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export class ComponentSetBuilder {
154154
componentSet = assertComponentSetIsNotUndefined(componentSet);
155155
componentSet.apiVersion ??= apiversion;
156156
componentSet.sourceApiVersion ??= sourceapiversion;
157+
componentSet.projectDirectory = projectDir;
157158

158159
logComponents(logger, componentSet);
159160
return componentSet;

src/convert/replacements.ts

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import { readFile } from 'node:fs/promises';
88
import { Transform, Readable } from 'node:stream';
9-
import { sep, posix } from 'node:path';
9+
import { sep, posix, join, isAbsolute } from 'node:path';
1010
import { Lifecycle, Messages, SfError, SfProject } from '@salesforce/core';
1111
import * as minimatch from 'minimatch';
1212
import { Env } from '@salesforce/kit';
@@ -91,10 +91,8 @@ export const getReplacementMarkingStream = async (
9191
projectDir?: string
9292
): Promise<ReplacementMarkingStream | undefined> => {
9393
// remove any that don't agree with current env
94-
const filteredReplacements = envFilter(await readReplacementsFromProject(projectDir));
95-
if (filteredReplacements.length) {
96-
return new ReplacementMarkingStream(filteredReplacements);
97-
}
94+
const filteredReplacements = (await readReplacementsFromProject(projectDir)).filter(envFilter);
95+
return filteredReplacements.length ? new ReplacementMarkingStream(filteredReplacements) : undefined;
9896
};
9997

10098
/**
@@ -117,8 +115,8 @@ class ReplacementMarkingStream extends Transform {
117115
if (!chunk.isMarkedForDelete() && this.replacementConfigs?.length) {
118116
try {
119117
chunk.replacements = await getReplacements(chunk, this.replacementConfigs);
120-
if (chunk.replacements && chunk.parent && chunk.type.name === 'CustomLabel') {
121-
// Set replacements on the parent of a CustomLabel as well so that recomposing
118+
if (chunk.replacements && chunk.parent?.type.strategies?.transformer === 'nonDecomposed') {
119+
// Set replacements on the parent of a nonDecomposed CustomLabel as well so that recomposing
122120
// doesn't use the non-replaced content from parent cache.
123121
// See RecompositionFinalizer.recompose() in convertContext.ts
124122
chunk.parent.replacements = chunk.replacements;
@@ -168,7 +166,7 @@ export const getReplacements = async (
168166
await Promise.all(
169167
replacementConfigs
170168
// filter out any that don't match the current file
171-
.filter((r) => matchesFile(f, r))
169+
.filter(matchesFile(f))
172170
.map(async (r) => ({
173171
matchedFilename: f,
174172
// used during replacement stream to limit warnings to explicit filenames, not globs
@@ -192,27 +190,26 @@ export const getReplacements = async (
192190
// filter out any that don't have any replacements
193191
.filter(([, replacements]) => replacements.length > 0);
194192

195-
if (replacementsForComponent.length) {
196-
// turn into a Dictionary-style object so it's easier to lookup by filename
197-
return Object.fromEntries(replacementsForComponent);
198-
}
193+
// turn into a Dictionary-style object so it's easier to lookup by filename
194+
return replacementsForComponent.length ? Object.fromEntries(replacementsForComponent) : undefined;
199195
};
200196

201-
export const matchesFile = (f: string, r: ReplacementConfig): boolean =>
202-
// filenames will be absolute. We don't have convenient access to the pkgDirs,
203-
// so we need to be more open than an exact match
204-
(typeof r.filename === 'string' && posixifyPaths(f).endsWith(r.filename)) ||
205-
(typeof r.glob === 'string' && minimatch(f, `**/${r.glob}`));
197+
export const matchesFile =
198+
(filename: string) =>
199+
(r: ReplacementConfig): boolean =>
200+
// filenames will be absolute. We don't have convenient access to the pkgDirs,
201+
// so we need to be more open than an exact match
202+
(typeof r.filename === 'string' && posixifyPaths(filename).endsWith(r.filename)) ||
203+
(typeof r.glob === 'string' && minimatch(filename, `**/${r.glob}`));
206204

207205
/**
208206
* Regardless of any components, return the ReplacementConfig that are valid with the current env.
209207
* These can be checked globally and don't need to be checked per component.
210208
*/
211-
const envFilter = (replacementConfigs: ReplacementConfig[] = []): ReplacementConfig[] =>
212-
replacementConfigs.filter(
213-
(replacement) =>
214-
!replacement.replaceWhenEnv ||
215-
replacement.replaceWhenEnv.every((envConditional) => process.env[envConditional.env] === envConditional.value)
209+
export const envFilter = (replacement: ReplacementConfig): boolean =>
210+
!replacement.replaceWhenEnv ||
211+
replacement.replaceWhenEnv.every(
212+
(envConditional) => process.env[envConditional.env] === envConditional.value.toString()
216213
);
217214

218215
/** A "getter" for envs to throw an error when an expected env is not present */
@@ -231,8 +228,8 @@ const readReplacementsFromProject = async (projectDir?: string): Promise<Replace
231228
try {
232229
const proj = await SfProject.resolve(projectDir);
233230
const projJson = (await proj.resolveProjectConfig()) as { replacements?: ReplacementConfig[] };
234-
235-
return projJson.replacements ?? [];
231+
const definiteProjectDir = proj.getPath();
232+
return (projJson.replacements ?? []).map(makeAbsolute(definiteProjectDir));
236233
} catch (e) {
237234
if (e instanceof SfError && e.name === 'InvalidProjectWorkspaceError') {
238235
return [];
@@ -249,3 +246,17 @@ export const stringToRegex = (input: string): RegExp =>
249246
new RegExp(input.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), 'g');
250247

251248
export const posixifyPaths = (f: string): string => f.split(sep).join(posix.sep);
249+
250+
/** if replaceWithFile is present, resolve it to an absolute path relative to the projectdir */
251+
const makeAbsolute =
252+
(projectDir: string) =>
253+
(replacementConfig: ReplacementConfig): ReplacementConfig =>
254+
replacementConfig.replaceWithFile
255+
? {
256+
...replacementConfig,
257+
// it could already be absolute?
258+
replaceWithFile: isAbsolute(replacementConfig.replaceWithFile)
259+
? replacementConfig.replaceWithFile
260+
: join(projectDir, replacementConfig.replaceWithFile),
261+
}
262+
: replacementConfig;

test/convert/replacements.test.ts

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
replacementIterations,
1515
stringToRegex,
1616
posixifyPaths,
17+
envFilter,
1718
} from '../../src/convert/replacements';
1819
import { matchingContentFile } from '../mock';
1920
import * as replacementsForMock from '../../src/convert/replacements';
@@ -23,29 +24,111 @@ config.truncateThreshold = 0;
2324
describe('file matching', () => {
2425
const base = { replaceWithEnv: 'foo', stringToReplace: 'foo' };
2526
it('file matches string', () => {
26-
expect(matchesFile('foo', { filename: 'foo', ...base })).to.be.true;
27-
expect(matchesFile('bar', { filename: 'foo', ...base })).to.not.be.true;
27+
expect(matchesFile('foo')({ filename: 'foo', ...base })).to.be.true;
28+
expect(matchesFile('bar')({ filename: 'foo', ...base })).to.not.be.true;
2829
});
2930
it('paths with separators to cover possibility of windows paths', () => {
30-
expect(matchesFile(path.join('foo', 'bar'), { filename: 'foo/bar', ...base })).to.be.true;
31-
expect(matchesFile(path.join('foo', 'bar'), { filename: 'foo/baz', ...base })).to.not.be.true;
31+
const fn = matchesFile(path.join('foo', 'bar'));
32+
expect(fn({ filename: 'foo/bar', ...base })).to.be.true;
33+
expect(fn({ filename: 'foo/baz', ...base })).to.not.be.true;
3234
});
3335
it('file matches glob (posix paths)', () => {
34-
expect(matchesFile('foo/bar', { glob: 'foo/**', ...base })).to.be.true;
35-
expect(matchesFile('foo/bar', { glob: 'foo/*', ...base })).to.be.true;
36-
expect(matchesFile('foo/bar', { glob: 'foo', ...base })).to.be.false;
37-
expect(matchesFile('foo/bar', { glob: '**/*', ...base })).to.be.true;
36+
const fn = matchesFile(path.join('foo', 'bar'));
37+
38+
expect(fn({ glob: 'foo/**', ...base })).to.be.true;
39+
expect(fn({ glob: 'foo/*', ...base })).to.be.true;
40+
expect(fn({ glob: 'foo', ...base })).to.be.false;
41+
expect(fn({ glob: '**/*', ...base })).to.be.true;
3842
});
3943
it('file matches glob (os-dependent paths)', () => {
40-
expect(matchesFile(path.join('foo', 'bar'), { glob: 'foo/**', ...base })).to.be.true;
41-
expect(matchesFile(path.join('foo', 'bar'), { glob: 'foo/*', ...base })).to.be.true;
42-
expect(matchesFile(path.join('foo', 'bar'), { glob: 'foo', ...base })).to.be.false;
43-
expect(matchesFile(path.join('foo', 'bar'), { glob: '**/*', ...base })).to.be.true;
44+
const fn = matchesFile(path.join('foo', 'bar'));
45+
expect(fn({ glob: 'foo/**', ...base })).to.be.true;
46+
expect(fn({ glob: 'foo/*', ...base })).to.be.true;
47+
expect(fn({ glob: 'foo', ...base })).to.be.false;
48+
expect(fn({ glob: '**/*', ...base })).to.be.true;
49+
});
50+
it('test absolute vs. relative paths', () => {
51+
const fn = matchesFile(path.join('/Usr', 'me', 'foo', 'bar'));
52+
expect(fn({ glob: 'foo/**', ...base })).to.be.true;
53+
expect(fn({ glob: 'foo/*', ...base })).to.be.true;
54+
expect(fn({ glob: 'foo', ...base })).to.be.false;
55+
expect(fn({ glob: '**/*', ...base })).to.be.true;
4456
});
45-
it('test absolute vs. relative paths');
4657
});
4758

48-
describe('env filters', () => {});
59+
describe('env filters', () => {
60+
beforeEach(() => {
61+
process.env.SHOULD_REPLACE_FOO = undefined;
62+
});
63+
it('true when not replaceWhenEnv', () => {
64+
expect(envFilter({ stringToReplace: 'foo', filename: '*', replaceWithFile: '/some/file' })).to.equal(true);
65+
});
66+
it('true when env is set and value matches string', () => {
67+
process.env.SHOULD_REPLACE_FOO = 'x';
68+
expect(
69+
envFilter({
70+
stringToReplace: 'foo',
71+
filename: '*',
72+
replaceWithFile: '/some/file',
73+
replaceWhenEnv: [{ env: 'SHOULD_REPLACE_FOO', value: 'x' }],
74+
})
75+
).to.equal(true);
76+
});
77+
it('true when env is set and value matches boolean', () => {
78+
process.env.SHOULD_REPLACE_FOO = 'true';
79+
expect(
80+
envFilter({
81+
stringToReplace: 'foo',
82+
filename: '*',
83+
replaceWithFile: '/some/file',
84+
replaceWhenEnv: [{ env: 'SHOULD_REPLACE_FOO', value: true }],
85+
})
86+
).to.equal(true);
87+
});
88+
it('true when env is set and value matches number', () => {
89+
process.env.SHOULD_REPLACE_FOO = '6';
90+
expect(
91+
envFilter({
92+
stringToReplace: 'foo',
93+
filename: '*',
94+
replaceWithFile: '/some/file',
95+
replaceWhenEnv: [{ env: 'SHOULD_REPLACE_FOO', value: 6 }],
96+
})
97+
).to.equal(true);
98+
});
99+
it('false when env is set and does not match number', () => {
100+
process.env.SHOULD_REPLACE_FOO = '6';
101+
expect(
102+
envFilter({
103+
stringToReplace: 'foo',
104+
filename: '*',
105+
replaceWithFile: '/some/file',
106+
replaceWhenEnv: [{ env: 'SHOULD_REPLACE_FOO', value: 7 }],
107+
})
108+
).to.equal(false);
109+
});
110+
it('false when env is set and does not match string', () => {
111+
process.env.SHOULD_REPLACE_FOO = 'x';
112+
expect(
113+
envFilter({
114+
stringToReplace: 'foo',
115+
filename: '*',
116+
replaceWithFile: '/some/file',
117+
replaceWhenEnv: [{ env: 'SHOULD_REPLACE_FOO', value: 'y' }],
118+
})
119+
).to.equal(false);
120+
});
121+
it('false when env is not set', () => {
122+
expect(
123+
envFilter({
124+
stringToReplace: 'foo',
125+
filename: '*',
126+
replaceWithFile: '/some/file',
127+
replaceWhenEnv: [{ env: 'SHOULD_REPLACE_FOO', value: 'true' }],
128+
})
129+
).to.equal(false);
130+
});
131+
});
49132

50133
describe('marking replacements on a component', () => {
51134
before(() => {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright (c) 2023, salesforce.com, inc.
3+
* All rights reserved.
4+
* Licensed under the BSD 3-Clause license.
5+
* For full license text, see LICENSE.txt file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
8+
import * as fs from 'node:fs';
9+
import * as path from 'node:path';
10+
import * as JSZip from 'jszip';
11+
12+
export const extractZip = async (zipBuffer: Buffer, extractPath: string) => {
13+
fs.mkdirSync(extractPath);
14+
const zip = await JSZip.loadAsync(zipBuffer);
15+
for (const filePath of Object.keys(zip.files)) {
16+
const zipObj = zip.file(filePath);
17+
if (!zipObj || zipObj?.dir) {
18+
fs.mkdirSync(path.join(extractPath, filePath));
19+
} else {
20+
// eslint-disable-next-line no-await-in-loop
21+
const content = await zipObj?.async('nodebuffer');
22+
if (content) {
23+
fs.writeFileSync(path.join(extractPath, filePath), content);
24+
}
25+
}
26+
}
27+
};

test/nuts/local/replacements/replacements.nut.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,11 @@ import * as JSZip from 'jszip';
1010
import { TestSession } from '@salesforce/cli-plugins-testkit';
1111
import { assert, expect } from 'chai';
1212
import { ComponentSetBuilder, MetadataConverter } from '../../../../src';
13+
import { extractZip } from './extractZip';
1314

1415
describe('e2e replacements test', () => {
1516
let session: TestSession;
1617

17-
const extractZip = async (zipBuffer: Buffer, extractPath: string) => {
18-
fs.mkdirSync(extractPath);
19-
const zip = await JSZip.loadAsync(zipBuffer);
20-
for (const filePath of Object.keys(zip.files)) {
21-
const zipObj = zip.file(filePath);
22-
if (!zipObj || zipObj?.dir) {
23-
fs.mkdirSync(path.join(extractPath, filePath));
24-
} else {
25-
// eslint-disable-next-line no-await-in-loop
26-
const content = await zipObj?.async('nodebuffer');
27-
if (content) {
28-
fs.writeFileSync(path.join(extractPath, filePath), content);
29-
}
30-
}
31-
}
32-
};
33-
3418
before(async () => {
3519
session = await TestSession.create({
3620
project: {
@@ -42,16 +26,7 @@ describe('e2e replacements test', () => {
4226
// Hack: rewrite the file replacement locations relative to the project
4327
const projectJsonPath = path.join(session.project.dir, 'sfdx-project.json');
4428
const original = await fs.promises.readFile(projectJsonPath, 'utf8');
45-
await fs.promises.writeFile(
46-
projectJsonPath,
47-
original
48-
// we're putting this in a json file which doesnt like windows backslashes. The file will require posix paths
49-
.replace(
50-
'replacements.txt',
51-
path.join(session.project.dir, 'replacements.txt').split(path.sep).join(path.posix.sep)
52-
)
53-
.replace('label.txt', path.join(session.project.dir, 'label.txt').split(path.sep).join(path.posix.sep))
54-
);
29+
await fs.promises.writeFile(projectJsonPath, original);
5530
});
5631

5732
after(async () => {

test/nuts/local/replacements/replacementsLabels.nut.ts

Lines changed: 2 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,27 +10,11 @@ import * as JSZip from 'jszip';
1010
import { TestSession } from '@salesforce/cli-plugins-testkit';
1111
import { assert, expect } from 'chai';
1212
import { ComponentSetBuilder, MetadataConverter } from '../../../../src';
13+
import { extractZip } from './extractZip';
1314

1415
describe('e2e replacements test (customLabels)', () => {
1516
let session: TestSession;
1617

17-
const extractZip = async (zipBuffer: Buffer, extractPath: string) => {
18-
fs.mkdirSync(extractPath);
19-
const zip = await JSZip.loadAsync(zipBuffer);
20-
for (const filePath of Object.keys(zip.files)) {
21-
const zipObj = zip.file(filePath);
22-
if (!zipObj || zipObj?.dir) {
23-
fs.mkdirSync(path.join(extractPath, filePath));
24-
} else {
25-
// eslint-disable-next-line no-await-in-loop
26-
const content = await zipObj?.async('nodebuffer');
27-
if (content) {
28-
fs.writeFileSync(path.join(extractPath, filePath), content);
29-
}
30-
}
31-
}
32-
};
33-
3418
before(async () => {
3519
session = await TestSession.create({
3620
project: {
@@ -41,16 +25,7 @@ describe('e2e replacements test (customLabels)', () => {
4125
// Hack: rewrite the file replacement locations relative to the project
4226
const projectJsonPath = path.join(session.project.dir, 'sfdx-project.json');
4327
const original = await fs.promises.readFile(projectJsonPath, 'utf8');
44-
await fs.promises.writeFile(
45-
projectJsonPath,
46-
original
47-
// we're putting this in a json file which doesnt like windows backslashes. The file will require posix paths
48-
.replace(
49-
'replacements.txt',
50-
path.join(session.project.dir, 'replacements.txt').split(path.sep).join(path.posix.sep)
51-
)
52-
.replace('label.txt', path.join(session.project.dir, 'label.txt').split(path.sep).join(path.posix.sep))
53-
);
28+
await fs.promises.writeFile(projectJsonPath, original);
5429
});
5530

5631
after(async () => {

0 commit comments

Comments
 (0)