Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ const PODS = PLAIN.replace(
'AA0000000000000000000901 /* Debug */ = {\n\t\t\tisa = XCBuildConfiguration;\n\t\t\tbaseConfigurationReference = BB0000000000000000000001 /* Pods-MyApp.debug.xcconfig */;\n\t\t\tbuildSettings = {',
);

// Derive a variant whose app-target configs already carry HEADER_SEARCH_PATHS,
// set to any valid pbxproj value: a plain scalar (which injection promotes to an
// array) or an array injection appends to.
function withHeaderSearchPaths(value) {
return PLAIN.replaceAll(
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
`HEADER_SEARCH_PATHS = ${value};\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`,
);
}

const RN_PATH = '../node_modules/react-native';

// Absolute, mirroring resolveHermesCliPathSetting (a `..`-relative path through
Expand Down Expand Up @@ -227,6 +237,50 @@ describe('injectSpmIntoPbxproj — Tier 2 (build settings + phase)', () => {
expect(syncIdx).toBeLessThan(sourcesIdx);
});

it.each([
[
'"$(inherited)"',
['"$(inherited)"', '"$(SRCROOT)/build/generated/autolinking/headers"'],
],
[
'"$(inherited) $(SRCROOT)/vendor/include"',
[
'"$(inherited)"',
'"$(inherited) $(SRCROOT)/vendor/include"',
'"$(SRCROOT)/build/generated/autolinking/headers"',
],
],
])(
'promotes a pre-existing HEADER_SEARCH_PATHS scalar (%s) to an array, keeping its value and one $(inherited)',
(scalar, expectedMembers) => {
const {text} = inject(withHeaderSearchPaths(scalar));
const arrays = [
...text.matchAll(/HEADER_SEARCH_PATHS = \(\n([\s\S]*?)\t+\);/g),
].map(m =>
m[1]
.split('\n')
.map(line => line.trim().replace(/,$/, ''))
.filter(member => member.length > 0),
);
// Both app-target configs (Debug + Release).
expect(arrays).toEqual([expectedMembers, expectedMembers]);
},
);

it('appends to a pre-existing ONE-LINE HEADER_SEARCH_PATHS array in place', () => {
const {text} = inject(withHeaderSearchPaths('("$(inherited)", )'));
expect(isBalanced(text)).toBe(true);
const arrays = [
...text.matchAll(/HEADER_SEARCH_PATHS = \(([^\n]*)\);/g),
].map(m => m[1]);
// Both app-target configs, each keeping the one-line shape it was written in.
expect(arrays).toEqual(
Array(2).fill(
'"$(inherited)", "$(SRCROOT)/build/generated/autolinking/headers", ',
),
);
});

it('adds one generated embed phase immediately after Frameworks', () => {
const {text} = inject(PLAIN);
expect(text).not.toContain('Fix SPM Embedded Flavor');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,44 @@ afterEach(() => {
scaffoldedAppRoots = [];
});

// Pre-existing values for an injected array setting (HEADER_SEARCH_PATHS),
// seeded into both app-target configs. The plain fixture has none, so it only
// ever exercises the create-from-absent path; deinit must restore each of
// these forms — a plain scalar is ordinary, valid pbxproj.
const PRE_EXISTING_HEADER_SEARCH_PATHS = {
'a bare $(inherited) scalar': '"$(inherited)"',
'a scalar with real content': '"$(inherited) $(SRCROOT)/vendor/include"',
'an array': '(\n\t\t\t\t"$(inherited)",\n\t\t\t)',
// What hand edits and other generators (XcodeGen, Tuist) write.
'a one-line array': '("$(inherited)", )',
};

// Seed a whole `KEY = value;` field (comments and stray whitespace included)
// into both app-target configs.
function withSetting(field /*: string */) {
return PLAIN.replaceAll(
'PRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;',
`${field}\n\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = com.example.MyApp;`,
);
}

function withHeaderSearchPaths(value /*: string */) {
return withSetting(`HEADER_SEARCH_PATHS = ${value};`);
}

// Build a throwaway app dir: <tmp>/MyApp.xcodeproj/project.pbxproj seeded with
// the plain (SPM-only) fixture, and a node_modules/react-native sibling so the
// relative reactNativePath resolves.
function scaffoldApp() {
function scaffoldApp(pbxproj = PLAIN) {
const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'spm-deinit-'));
scaffoldedAppRoots.push(appRoot);
const xcodeprojPath = path.join(appRoot, 'MyApp.xcodeproj');
fs.mkdirSync(xcodeprojPath, {recursive: true});
fs.writeFileSync(path.join(xcodeprojPath, 'project.pbxproj'), PLAIN, 'utf8');
fs.writeFileSync(
path.join(xcodeprojPath, 'project.pbxproj'),
pbxproj,
'utf8',
);
const rnRoot = path.join(appRoot, 'node_modules', 'react-native');
fs.mkdirSync(rnRoot, {recursive: true});
const artifactRoot = path.join(appRoot, 'build', 'xcframeworks');
Expand Down Expand Up @@ -189,6 +218,144 @@ describe('removeSpmInjection — the surgical inverse of add', () => {
});
});

describe.each(Object.entries(PRE_EXISTING_HEADER_SEARCH_PATHS))(
'removeSpmInjection with HEADER_SEARCH_PATHS already set to %s',
(_label, value) => {
it('restores the pre-existing value byte-for-byte', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
withHeaderSearchPaths(value),
);
const before = pbxprojOf(xcodeprojPath);

injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});
expect(pbxprojOf(xcodeprojPath)).not.toBe(before);

expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe(
'removed',
);
expect(pbxprojOf(xcodeprojPath)).toBe(before);
});

it('re-syncing is byte-for-byte identical', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
withHeaderSearchPaths(value),
);
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});
const first = pbxprojOf(xcodeprojPath);
injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});
expect(pbxprojOf(xcodeprojPath)).toBe(first);
});
},
);

// findField's token for a BARE scalar ends AT the `;`, so it includes any
// whitespace before it. Deinit must put those bytes back exactly, not a
// tidied-up version of them.
describe.each([
'HEADER_SEARCH_PATHS = $(inherited) ; /* note */',
'HEADER_SEARCH_PATHS = ;',
])('removeSpmInjection with the untrimmed scalar `%s`', field => {
it('restores it byte-for-byte', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(field));
const before = pbxprojOf(xcodeprojPath);

injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});
expect(pbxprojOf(xcodeprojPath)).not.toBe(before);

expect(removeSpmInjection({appRoot, xcodeprojPath}).status).toBe('removed');
expect(pbxprojOf(xcodeprojPath)).toBe(before);
});
});

describe('a scalar array setting injection has nothing to add to', () => {
const SCALAR = 'FRAMEWORK_SEARCH_PATHS = "$(inherited)";';
const EDITED = 'FRAMEWORK_SEARCH_PATHS = "$(inherited) $(SRCROOT)/Vendor";';

// The fixture's flavored-frameworks manifest is empty, so
// FRAMEWORK_SEARCH_PATHS is injected with no values at all.
it('is left untouched, unrecorded, and survives a later user edit', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(withSetting(SCALAR));

injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});

const injected = pbxprojOf(xcodeprojPath);
expect(injected).toContain(SCALAR);
expect(injected).not.toMatch(/FRAMEWORK_SEARCH_PATHS = \(/);
for (const change of readMarker(xcodeprojPath).buildSettingChanges) {
expect(change.promotedArrayScalars ?? {}).not.toHaveProperty(
'FRAMEWORK_SEARCH_PATHS',
);
}

fs.writeFileSync(
path.join(xcodeprojPath, 'project.pbxproj'),
injected.replaceAll(SCALAR, EDITED),
'utf8',
);
removeSpmInjection({appRoot, xcodeprojPath});

const after = pbxprojOf(xcodeprojPath);
expect(after).toContain(EDITED);
expect(after).not.toContain(SCALAR);
});
});

describe('a promoted array setting the user deleted after add', () => {
const SCALAR = '"$(inherited) $(SRCROOT)/vendor/include"';

it('is not resurrected by deinit', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp(
withHeaderSearchPaths(SCALAR),
);
const before = pbxprojOf(xcodeprojPath);

injectSpmIntoExistingXcodeproj({
appRoot,
reactNativeRoot: rnRoot,
xcodeprojPath,
});

const deleted = pbxprojOf(xcodeprojPath).replace(
/\n\t+HEADER_SEARCH_PATHS = \(\n[\s\S]*?\n\t+\);/g,
'',
);
expect(deleted).not.toContain('HEADER_SEARCH_PATHS');
fs.writeFileSync(
path.join(xcodeprojPath, 'project.pbxproj'),
deleted,
'utf8',
);

removeSpmInjection({appRoot, xcodeprojPath});

// Everything else is back to its pre-injection bytes; only the setting the
// user deleted stays gone.
expect(pbxprojOf(xcodeprojPath)).toBe(
before.replaceAll(`\n\t\t\t\tHEADER_SEARCH_PATHS = ${SCALAR};`, ''),
);
});
});

describe('generated-sources reconciliation on update', () => {
it('removes exactly the UUIDs of an entry dropped from the manifest, keeping the rest', () => {
const {appRoot, xcodeprojPath, rnRoot} = scaffoldApp();
Expand Down
Loading
Loading