Skip to content

Commit dd4a0d4

Browse files
committed
Address review: follow #include chains, exact include line, default flavor, embed script input, safe deinit
1 parent abadd69 commit dd4a0d4

6 files changed

Lines changed: 657 additions & 107 deletions

File tree

‎packages/react-native/scripts/spm/__tests__/generate-spm-xcodeproj-test.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,7 @@ OTHER_LDFLAGS = $(inherited) -ObjC "$(RN_SPM_REACT_BINARY)"
627627
FRAMEWORK_SEARCH_PATHS = $(inherited) "$(RN_SPM_REACT_SEARCH_PATH)"
628628
LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks
629629
630+
RN_SPM_FLAVOR = release
630631
RN_SPM_FLAVOR[config=Debug] = debug
631632
SWIFT_ACTIVE_COMPILATION_CONDITIONS[config=Debug] = $(inherited) DEBUG
632633
RN_SPM_FLAVOR[config=Release] = release
@@ -661,6 +662,27 @@ RN_SPM_REACT_SEARCH_PATH[sdk=iphoneos*] = $(SRCROOT)/build/xcframeworks/$(RN_SPM
661662
expect(xcconfig).not.toMatch(/\[config=(Debug|Release)\]/);
662663
});
663664

665+
// A configuration duplicated in Xcode keeps the base reference but has no
666+
// line of its own until the next sync, and an unset flavor would resolve
667+
// every framework path to nothing.
668+
it('falls back to the release flavor for a configuration it does not list', () => {
669+
const lines = generateReactNativeXcconfig(
670+
['Debug', 'Release'],
671+
'../node_modules/react-native',
672+
[FRAMEWORK],
673+
).split('\n');
674+
const fallback = lines.indexOf('RN_SPM_FLAVOR = release');
675+
expect(fallback).toBeGreaterThan(-1);
676+
expect(fallback).toBeLessThan(
677+
lines.findIndex(line => line.startsWith('RN_SPM_FLAVOR[config=')),
678+
);
679+
expect(
680+
lines.some(line =>
681+
line.startsWith('SWIFT_ACTIVE_COMPILATION_CONDITIONS ='),
682+
),
683+
).toBe(false);
684+
});
685+
664686
it('writes no plist quoting around conditional keys or path values', () => {
665687
const xcconfig = generateReactNativeXcconfig(
666688
['Debug'],

‎packages/react-native/scripts/spm/__tests__/inject-spm-xcodeproj-test.js‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const {
2020
const {findField, findObjectByUuid, quoteIfNeeded} = require('../spm-pbxproj');
2121
const {isBalanced} = require('./pbxproj-oracles');
2222
const fs = require('node:fs');
23+
const os = require('node:os');
2324
const path = require('node:path');
2425

2526
const PLAIN = fs.readFileSync(
@@ -270,6 +271,171 @@ describe('planInjection', () => {
270271
});
271272
});
272273

274+
// FOREIGN_XCCONFIG with the reference inside a `Config` group, the way Xcode
275+
// files an xcconfig dragged into a folder of the navigator.
276+
const FOREIGN_XCCONFIG_IN_GROUP = FOREIGN_XCCONFIG.replace(
277+
'path = Config/App.xcconfig;',
278+
'path = App.xcconfig;',
279+
)
280+
.replace(
281+
'\t\t\t\tAA00000000000000000000F1 /* Products */,\n',
282+
'\t\t\t\tAA00000000000000000000F1 /* Products */,\n\t\t\t\tCC0000000000000000000002 /* Config */,\n',
283+
)
284+
.replace(
285+
'/* End PBXGroup section */',
286+
'\t\tCC0000000000000000000002 /* Config */ = {\n\t\t\tisa = PBXGroup;\n\t\t\tchildren = (\n\t\t\t\tCC0000000000000000000001 /* App.xcconfig */,\n\t\t\t);\n\t\t\tpath = Config;\n\t\t\tsourceTree = "<group>";\n\t\t};\n/* End PBXGroup section */',
287+
);
288+
289+
describe("planInjection — a configuration based on the user's own xcconfig", () => {
290+
let roots = [];
291+
afterEach(() => {
292+
for (const root of roots) {
293+
fs.rmSync(root, {recursive: true, force: true});
294+
}
295+
roots = [];
296+
});
297+
298+
// An app root (== SRCROOT) holding `files`, app-root-relative path → content.
299+
function appRootWith(files) {
300+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-include-'));
301+
roots.push(root);
302+
for (const [relPath, content] of Object.entries(files)) {
303+
fs.mkdirSync(path.dirname(path.join(root, relPath)), {recursive: true});
304+
fs.writeFileSync(path.join(root, relPath), content, 'utf8');
305+
}
306+
return root;
307+
}
308+
309+
function plan(files, text = FOREIGN_XCCONFIG) {
310+
const root = appRootWith(files);
311+
return planInjection(text, {appRoot: root, srcRoot: root});
312+
}
313+
314+
const INCLUDES_GENERATED = `#include "../${XCCONFIG_PATH}"\n`;
315+
316+
it('accepts it when the xcconfig #includes the generated one', () => {
317+
const result = plan({'Config/App.xcconfig': INCLUDES_GENERATED});
318+
expect(result.ok).toBe(true);
319+
expect(result.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]);
320+
});
321+
322+
it('includes no configuration it bases on the generated xcconfig itself', () => {
323+
expect(planInjection(PLAIN, {}).includedByConfigUuids).toEqual([]);
324+
});
325+
326+
it('finds an xcconfig filed under a navigator group', () => {
327+
expect(
328+
plan(
329+
{'Config/App.xcconfig': INCLUDES_GENERATED},
330+
FOREIGN_XCCONFIG_IN_GROUP,
331+
).ok,
332+
).toBe(true);
333+
});
334+
335+
it('follows #include? chains, each path relative to the file that holds it', () => {
336+
expect(
337+
plan({
338+
'Config/App.xcconfig':
339+
'// Shared settings\n#include? "Shared/Base.xcconfig"\n',
340+
'Config/Shared/Base.xcconfig': `#include "../../${XCCONFIG_PATH}"\n`,
341+
}).ok,
342+
).toBe(true);
343+
});
344+
345+
it.each([
346+
[
347+
'a path relative to the project directory',
348+
`#include "${XCCONFIG_PATH}"\n`,
349+
],
350+
['a commented-out include', `// ${INCLUDES_GENERATED}`],
351+
['an include of a file that is missing', '#include? "Missing.xcconfig"\n'],
352+
])('refuses %s', (_label, content) => {
353+
expect(plan({'Config/App.xcconfig': content}).ok).toBe(false);
354+
});
355+
356+
it('refuses an include cycle that never reaches the generated xcconfig', () => {
357+
const result = plan({
358+
'Config/App.xcconfig': '#include "Base.xcconfig"\n',
359+
'Config/Base.xcconfig': '#include "App.xcconfig"\n',
360+
});
361+
expect(result.ok).toBe(false);
362+
});
363+
364+
it('still refuses a list setting without $(inherited) on it', () => {
365+
const result = plan(
366+
{'Config/App.xcconfig': INCLUDES_GENERATED},
367+
FOREIGN_XCCONFIG.replaceAll(
368+
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
369+
'HEADER_SEARCH_PATHS = /vendor;\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
370+
),
371+
);
372+
expect(result.ok).toBe(false);
373+
expect(result.reason).toContain('HEADER_SEARCH_PATHS (Debug, Release)');
374+
});
375+
376+
it('names the exact line to add, relative to the xcconfig that needs it', () => {
377+
const result = plan({'Config/App.xcconfig': 'MY_SETTING = 1\n'});
378+
expect(result.ok).toBe(false);
379+
expect(result.reason).toContain(
380+
`#include "../${XCCONFIG_PATH}" to Config/App.xcconfig`,
381+
);
382+
expect(result.reason).not.toContain('project directory');
383+
});
384+
385+
it('says the path is relative to the xcconfig when it cannot find the file', () => {
386+
const result = plan({});
387+
expect(result.ok).toBe(false);
388+
expect(result.reason).toContain(
389+
`#include ${XCCONFIG_PATH} from Config/App.xcconfig`,
390+
);
391+
expect(result.reason).toContain(
392+
'relative to the folder that holds Config/App.xcconfig',
393+
);
394+
expect(result.reason).not.toContain('project directory');
395+
});
396+
397+
it("keeps the user's base and records the configuration as including the generated xcconfig", () => {
398+
const root = appRootWith({'Config/App.xcconfig': INCLUDES_GENERATED});
399+
const accepted = planInjection(FOREIGN_XCCONFIG, {
400+
appRoot: root,
401+
srcRoot: root,
402+
});
403+
const {text, xcconfig, generatedFiles} = injectSpmIntoPbxproj(
404+
FOREIGN_XCCONFIG,
405+
{
406+
rootUuid: accepted.rootUuid,
407+
targetUuid: accepted.target.uuid,
408+
configUuids: accepted.configUuids,
409+
includedByConfigUuids: accepted.includedByConfigUuids,
410+
frameworksPhaseUuid: accepted.frameworksPhaseUuid,
411+
sourcesPhaseUuid: accepted.sourcesPhaseUuid,
412+
},
413+
RN_PATHS,
414+
null,
415+
[],
416+
TEST_FRAMEWORKS,
417+
);
418+
const baseOf = configUuid =>
419+
findField(
420+
text,
421+
findObjectByUuid(text, configUuid),
422+
'baseConfigurationReference',
423+
)?.value;
424+
expect(baseOf(APP_DEBUG_CONFIG)).toBe(
425+
'CC0000000000000000000001 /* App.xcconfig */',
426+
);
427+
expect(baseOf(APP_RELEASE_CONFIG)).toBe(
428+
`${xcconfig.fileRefUuid} /* ReactNativeSPM.xcconfig */`,
429+
);
430+
expect(xcconfig.configUuids).toEqual([APP_RELEASE_CONFIG]);
431+
expect(xcconfig.includedByConfigUuids).toEqual([APP_DEBUG_CONFIG]);
432+
expect(text).toContain(`path = ${XCCONFIG_PATH};`);
433+
expect(generatedFiles[XCCONFIG_PATH]).toContain(
434+
'RN_SPM_FLAVOR[config=Debug] = debug\n',
435+
);
436+
});
437+
});
438+
273439
describe('injectSpmIntoPbxproj — Tier 1 (SPM graph)', () => {
274440
it('adds the local package references and product dependencies', () => {
275441
const {text} = inject(PLAIN);
@@ -564,6 +730,19 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
564730
'$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/React.framework',
565731
);
566732
});
733+
734+
// The phase only runs the script file, so the file has to be an input for
735+
// Xcode to re-run the phase when the file alone changes.
736+
it('lists its script file among the embed phase inputs', () => {
737+
const {text} = inject(PLAIN);
738+
const uuid =
739+
/([0-9A-F]{24}) \/\* Embed React Native Flavored Frameworks \*\/ = \{/.exec(
740+
text,
741+
)[1];
742+
expect(
743+
findField(text, findObjectByUuid(text, uuid), 'inputPaths').value,
744+
).toContain(`${quoteIfNeeded(`$(SRCROOT)/${EMBED_SCRIPT_PATH}`)},`);
745+
});
567746
});
568747

569748
describe('injectSpmIntoPbxproj — Tier 3 (plugin generated sources)', () => {

‎packages/react-native/scripts/spm/__tests__/remove-spm-injection-test.js‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,42 @@ function generatedFileContents(appRoot) {
186186
);
187187
}
188188

189+
const APP_DEBUG_CONFIG = 'AA0000000000000000000901';
190+
191+
// The app target's Debug configuration based on Config/App.xcconfig, an
192+
// xcconfig of the user's own.
193+
const FOREIGN_XCCONFIG = PLAIN.replace(
194+
`${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n`,
195+
`${APP_DEBUG_CONFIG} /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n`,
196+
).replace(
197+
'/* End PBXFileReference section */',
198+
'\t\tCC0000000000000000000001 /* App.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/App.xcconfig; sourceTree = "<group>"; };\n/* End PBXFileReference section */',
199+
);
200+
201+
function writeUserXcconfig(appRoot, content) {
202+
fs.mkdirSync(path.join(appRoot, 'Config'), {recursive: true});
203+
fs.writeFileSync(path.join(appRoot, 'Config', 'App.xcconfig'), content);
204+
}
205+
206+
// What re-basing Debug on the user's own xcconfig in Xcode does after `add`.
207+
function rebaseDebugOnUserXcconfig(xcodeprojPath) {
208+
const text = pbxprojOf(xcodeprojPath);
209+
const start = text.indexOf(`${APP_DEBUG_CONFIG} /* Debug */ = {`);
210+
const end = text.indexOf('buildSettings', start);
211+
fs.writeFileSync(
212+
path.join(xcodeprojPath, 'project.pbxproj'),
213+
text.slice(0, start) +
214+
text
215+
.slice(start, end)
216+
.replace(
217+
/baseConfigurationReference = [0-9A-F]{24} \/\* ReactNativeSPM\.xcconfig \*\//,
218+
'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */',
219+
) +
220+
text.slice(end),
221+
'utf8',
222+
);
223+
}
224+
189225
function schemePathOf(xcodeprojPath) {
190226
return path.join(
191227
xcodeprojPath,
@@ -535,6 +571,95 @@ describe('removeSpmInjection — the surgical inverse of add', () => {
535571
expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true);
536572
});
537573

574+
it('keeps the xcconfig while one configuration is re-based on a user xcconfig, removing the scripts', () => {
575+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
576+
injectSpmIntoExistingXcodeproj({
577+
appRoot,
578+
reactNativeRoot: rnRoot,
579+
xcodeprojPath,
580+
});
581+
rebaseDebugOnUserXcconfig(xcodeprojPath);
582+
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
583+
584+
removeSpmInjection({appRoot, xcodeprojPath});
585+
586+
const logged = logSpy.mock.calls.map(([line]) => line).join('\n');
587+
logSpy.mockRestore();
588+
expect(pbxprojOf(xcodeprojPath)).toContain(
589+
'baseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;',
590+
);
591+
expect(pbxprojOf(xcodeprojPath)).not.toContain('ReactNativeSPM.xcconfig');
592+
expect(
593+
generatedFileContents(appRoot).map(content => content != null),
594+
).toEqual([true, false, false]);
595+
expect(logged).toContain(`Kept ${XCCONFIG_REL}`);
596+
});
597+
598+
it('accepts a user xcconfig that #includes the generated one, across syncs and deinit', () => {
599+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG);
600+
writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`);
601+
const sync = () =>
602+
injectSpmIntoExistingXcodeproj({
603+
appRoot,
604+
reactNativeRoot: rnRoot,
605+
xcodeprojPath,
606+
});
607+
608+
expect(sync().status).toBe('injected');
609+
const injected = pbxprojOf(xcodeprojPath);
610+
expect(readMarker(xcodeprojPath).xcconfig.includedByConfigUuids).toEqual([
611+
APP_DEBUG_CONFIG,
612+
]);
613+
expect(sync().status).toBe('injected');
614+
expect(pbxprojOf(xcodeprojPath)).toBe(injected);
615+
616+
removeSpmInjection({appRoot, xcodeprojPath});
617+
expect(pbxprojOf(xcodeprojPath)).toBe(FOREIGN_XCCONFIG);
618+
expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true);
619+
});
620+
621+
it('keeps the xcconfig a recorded configuration included, even once its base is gone', () => {
622+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(FOREIGN_XCCONFIG);
623+
writeUserXcconfig(appRoot, `#include "../${XCCONFIG_REL}"\n`);
624+
injectSpmIntoExistingXcodeproj({
625+
appRoot,
626+
reactNativeRoot: rnRoot,
627+
xcodeprojPath,
628+
});
629+
fs.writeFileSync(
630+
path.join(xcodeprojPath, 'project.pbxproj'),
631+
pbxprojOf(xcodeprojPath).replace(
632+
'\t\t\tbaseConfigurationReference = CC0000000000000000000001 /* App.xcconfig */;\n',
633+
'',
634+
),
635+
'utf8',
636+
);
637+
638+
removeSpmInjection({appRoot, xcodeprojPath});
639+
expect(fs.existsSync(path.join(appRoot, XCCONFIG_REL))).toBe(true);
640+
});
641+
642+
it('deletes the xcconfig for a marker written before includes were recorded', () => {
643+
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
644+
injectSpmIntoExistingXcodeproj({
645+
appRoot,
646+
reactNativeRoot: rnRoot,
647+
xcodeprojPath,
648+
});
649+
const marker = readMarker(xcodeprojPath);
650+
delete marker.xcconfig.includedByConfigUuids;
651+
fs.writeFileSync(
652+
path.join(xcodeprojPath, SPM_INJECTED_MARKER),
653+
JSON.stringify(marker, null, 2) + '\n',
654+
);
655+
656+
removeSpmInjection({appRoot, xcodeprojPath});
657+
expect(pbxprojOf(xcodeprojPath)).toBe(PLAIN);
658+
expect(generatedFileContents(appRoot)).toEqual(
659+
GENERATED_FILES.map(() => null),
660+
);
661+
});
662+
538663
// A ${PODS_ROOT}-anchored value resolves empty without CocoaPods, so putting
539664
// it back would only restore a broken setting.
540665
it('does not restore the ${PODS_ROOT}-anchored REACT_NATIVE_PATH it removed', () => {

0 commit comments

Comments
 (0)