diff --git a/src/api/__tests__/PBXFileReference.test.ts b/src/api/__tests__/PBXFileReference.test.ts index c951d07..0326623 100644 --- a/src/api/__tests__/PBXFileReference.test.ts +++ b/src/api/__tests__/PBXFileReference.test.ts @@ -1,147 +1,758 @@ import path from "path"; -import { PBXContainerItemProxy, PBXFileReference, XcodeProject } from ".."; +import { + PBXContainerItemProxy, + PBXFileReference, + XcodeProject, + PBXGroup, + PBXBuildFile, + PBXNativeTarget, +} from ".."; const WORKING_FIXTURE = path.join( __dirname, "../../json/__tests__/fixtures/AFNetworking.pbxproj" ); -it(`adds deterministic UUIDs without collision`, () => { - const xcproj = XcodeProject.open(WORKING_FIXTURE); +const MULTITARGET_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/project-multitarget.pbxproj" +); + +const WATCH_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/watch.pbxproj" +); + +describe("PBXFileReference", () => { + describe("creation and UUIDs", () => { + it("adds deterministic UUIDs without collision", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // @ts-expect-error: Prove that adding a random UUID to the project doesn't change the UUIDs of other objects + xcproj.set(Math.random().toString(), {}); + + const containerItemProxy = PBXContainerItemProxy.create(xcproj, { + containerPortal: xcproj.rootObject, + proxyType: 1, + remoteGlobalIDString: "xxx", + remoteInfo: "xxx", + }); + expect(containerItemProxy.uuid).toBe("XX6D16E1EEB44DF363DCC9XX"); + + const ref = PBXFileReference.create(xcproj, { + path: "a.swift", + }); + + expect(ref.uuid).toBe("XXDBE740AD7C410FE2DF44XX"); + expect( + PBXFileReference.create(xcproj, { + path: "a.swift", + }).uuid + ).toBe("XX5BBE738DD91F7523452AXX"); + }); - // @ts-expect-error: Prove that adding a random UUID to the project doesn't change the UUIDs of other objects - xcproj.set(Math.random().toString(), {}); + it("should create file reference with minimal options", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "TestFile.swift", + }); - const containerItemProxy = PBXContainerItemProxy.create(xcproj, { - containerPortal: xcproj.rootObject, - proxyType: 1, - remoteGlobalIDString: "xxx", - remoteInfo: "xxx", + expect(ref).toBeDefined(); + expect(ref.props.path).toBe("TestFile.swift"); + expect(ref.props.isa).toBe("PBXFileReference"); + }); }); - expect(containerItemProxy.uuid).toBe("XX6D16E1EEB44DF363DCC9XX"); - const ref = PBXFileReference.create(xcproj, { - path: "a.swift", + describe("setupDefaults", () => { + it("adds framework file with correct defaults for name", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + const ref = PBXFileReference.create(xcproj, { + path: "System/Library/Frameworks/SwiftUI.framework", + }); + + expect(ref.uuid).toBe("XX4DFF38D47332D6BF0183XX"); + + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: undefined, + isa: "PBXFileReference", + name: "SwiftUI.framework", + path: "System/Library/Frameworks/SwiftUI.framework", + lastKnownFileType: "wrapper.framework", + sourceTree: "SDKROOT", + }); + }); + + it("should set default file encoding", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + expect(ref.props.fileEncoding).toBe(4); + }); + + it("should set includeInIndex for regular files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + expect(ref.props.includeInIndex).toBe(0); + }); + + it("should clear includeInIndex for framework files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "TestFramework.framework", + }); + + expect(ref.props.includeInIndex).toBeUndefined(); + }); + + it("should set name from path basename when different", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "folder/subfolder/test.swift", + }); + + expect(ref.props.name).toBe("test.swift"); + }); + + it("should not set name when same as path", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + // When path basename equals path, name should NOT be set (this is the actual behavior) + expect(ref.props.name).toBeUndefined(); + }); }); - expect(ref.uuid).toBe("XXDBE740AD7C410FE2DF44XX"); - expect( - PBXFileReference.create(xcproj, { - path: "a.swift", - }).uuid - ).toBe("XX5BBE738DD91F7523452AXX"); -}); + describe("file type detection", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + it("adds swift file", () => { + const ref = PBXFileReference.create(xcproj, { + path: "fun/funky.swift", + }); -it(`adds framework file with correct defaults for name`, () => { - const xcproj = XcodeProject.open(WORKING_FIXTURE); + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: 0, + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.swift", + name: "funky.swift", + path: "fun/funky.swift", + sourceTree: "", + }); + }); + + it("adds css file", () => { + const ref = PBXFileReference.create(xcproj, { + path: "fun/funky.css", + }); + + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: 0, + isa: "PBXFileReference", + lastKnownFileType: "text.css", + name: "funky.css", + path: "fun/funky.css", + sourceTree: "", + }); + }); + + it("adds html file", () => { + const ref = PBXFileReference.create(xcproj, { + path: "fun/funky.html", + }); + + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: 0, + isa: "PBXFileReference", + lastKnownFileType: "text.html", + name: "funky.html", + path: "fun/funky.html", + sourceTree: "", + }); + }); + + it("adds json file", () => { + const ref = PBXFileReference.create(xcproj, { + path: "fun/funky.json", + }); + + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: 0, + isa: "PBXFileReference", + lastKnownFileType: "text.json", + name: "funky.json", + path: "fun/funky.json", + sourceTree: "", + }); + }); - const ref = PBXFileReference.create(xcproj, { - path: "System/Library/Frameworks/SwiftUI.framework", + it("adds js file", () => { + const ref = PBXFileReference.create(xcproj, { + path: "fun/funky.js", + }); + + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: 0, + isa: "PBXFileReference", + lastKnownFileType: "sourcecode.javascript", + name: "funky.js", + path: "fun/funky.js", + sourceTree: "", + }); + }); + + it("adds random file without extension", () => { + const ref = PBXFileReference.create(xcproj, { + path: "fun/funky", + }); + + expect(ref.props).toEqual({ + fileEncoding: 4, + includeInIndex: 0, + isa: "PBXFileReference", + name: "funky", + path: "fun/funky", + sourceTree: "", + }); + }); + + it("should handle .m Objective-C files", () => { + const ref = PBXFileReference.create(xcproj, { + path: "TestFile.m", + }); + + expect(ref.props.lastKnownFileType).toBe("sourcecode.c.objc"); + }); + + it("should handle .h header files", () => { + const ref = PBXFileReference.create(xcproj, { + path: "TestFile.h", + }); + + expect(ref.props.lastKnownFileType).toBe("sourcecode.c.h"); + }); }); - expect(ref.uuid).toBe("XX4DFF38D47332D6BF0183XX"); + describe("setLastKnownFileType", () => { + it("should set file type manually", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.unknown", + }); - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: undefined, - isa: "PBXFileReference", - name: "SwiftUI.framework", - path: "System/Library/Frameworks/SwiftUI.framework", - lastKnownFileType: "wrapper.framework", - sourceTree: "SDKROOT", + ref.setLastKnownFileType("sourcecode.swift"); + expect(ref.props.lastKnownFileType).toBe("sourcecode.swift"); + }); + + it("should detect file type from extension", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.unknown", + }); + + // Clear the automatically set type + ref.props.lastKnownFileType = undefined; + + // Change path and detect type + ref.props.path = "test.swift"; + ref.setLastKnownFileType(); + expect(ref.props.lastKnownFileType).toBe("sourcecode.swift"); + }); + + it("should handle files without extensions", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "README", + }); + + ref.setLastKnownFileType(); + expect(ref.props.lastKnownFileType).toBeUndefined(); + }); }); -}); -describe("file defaults", () => { - const xcproj = XcodeProject.open(WORKING_FIXTURE); - it(`adds swift file`, () => { - const ref = PBXFileReference.create(xcproj, { - path: "fun/funky.swift", + describe("setExplicitFileType", () => { + it("should set explicit file type manually", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + ref.setExplicitFileType("wrapper.application"); + expect(ref.props.explicitFileType).toBe("wrapper.application"); + expect(ref.props.lastKnownFileType).toBeUndefined(); }); - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: 0, - isa: "PBXFileReference", - lastKnownFileType: "sourcecode.swift", - name: "funky.swift", - path: "fun/funky.swift", - sourceTree: "", + it("should detect explicit file type from extension", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.app", + }); + + ref.setExplicitFileType(); + expect(ref.props.explicitFileType).toBe("wrapper.application"); + expect(ref.props.lastKnownFileType).toBeUndefined(); + }); + + it("should clear lastKnownFileType when explicitFileType is set", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + expect(ref.props.lastKnownFileType).toBe("sourcecode.swift"); + + ref.setExplicitFileType("wrapper.application"); + expect(ref.props.explicitFileType).toBe("wrapper.application"); + expect(ref.props.lastKnownFileType).toBeUndefined(); }); }); - it(`adds css file`, () => { - const ref = PBXFileReference.create(xcproj, { - path: "fun/funky.css", + + describe("getDisplayName", () => { + it("should return name property when set", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "folder/test.swift", + name: "CustomName.swift", + }); + + expect(ref.getDisplayName()).toBe("CustomName.swift"); }); - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: 0, - isa: "PBXFileReference", - lastKnownFileType: "text.css", - name: "funky.css", - path: "fun/funky.css", - sourceTree: "", + it("should return path for BUILT_PRODUCTS_DIR", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyApp.app", + sourceTree: "BUILT_PRODUCTS_DIR", + }); + + expect(ref.getDisplayName()).toBe("MyApp.app"); + }); + + it("should return basename of path when no name", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "folder/subfolder/test.swift", + }); + + // Remove name to test fallback + ref.props.name = undefined; + expect(ref.getDisplayName()).toBe("test.swift"); + }); + + it("should return fallback ISA name when no path or name", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + // Remove both name and path + ref.props.name = undefined; + ref.props.path = undefined; + expect(ref.getDisplayName()).toBe("FileReference"); }); }); - it(`adds html file`, () => { - const ref = PBXFileReference.create(xcproj, { - path: "fun/funky.html", + + describe("path management", () => { + it("should get parent group", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "test.swift", + }); + + const parent = ref.getParent(); + expect(parent).toBe(mainGroup); }); - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: 0, - isa: "PBXFileReference", - lastKnownFileType: "text.html", - name: "funky.html", - path: "fun/funky.html", - sourceTree: "", + it("should get all parents", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const subGroup = mainGroup.createGroup({ + name: "SubGroup", + sourceTree: "", + }); + const ref = subGroup.createFile({ + path: "test.swift", + }); + + const parents = ref.getParents(); + expect(parents).toContain(subGroup); + expect(parents).toContain(mainGroup); + }); + + it("should move to different parent", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const group1 = mainGroup.createGroup({ + name: "Group1", + sourceTree: "", + }); + const group2 = mainGroup.createGroup({ + name: "Group2", + sourceTree: "", + }); + + const ref = group1.createFile({ + path: "test.swift", + }); + + expect(ref.getParent()).toBe(group1); + + ref.move(group2); + expect(ref.getParent()).toBe(group2); + expect(group1.props.children).not.toContain(ref); + expect(group2.props.children).toContain(ref); + }); + + it("should set path with source tree", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "test.swift", + }); + + ref.setPath("new/path/test.swift"); + // The setPath method resolves paths to absolute paths for most source trees + expect(ref.props.path).toContain("new/path/test.swift"); + }); + + it("should clear path when undefined", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + ref.setPath(undefined); + expect(ref.props.path).toBeUndefined(); + }); + }); + + describe("getBuildFiles", () => { + it("should return build files that reference this file", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + // Create a file reference + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "TestFile.swift", + }); + + // Add it to sources build phase + const sourcesPhase = target!.getSourcesBuildPhase(); + const buildFile = sourcesPhase.ensureFile({ fileRef: ref }); + + const buildFiles = ref.getBuildFiles(); + expect(buildFiles).toContain(buildFile); + expect(buildFiles.length).toBeGreaterThan(0); + }); + + it("should return empty array for files not in build phases", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "UnusedFile.swift", + }); + + const buildFiles = ref.getBuildFiles(); + expect(buildFiles).toEqual([]); + }); + }); + + describe("getTargetReferrers", () => { + it("should return targets that reference this file as product", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + if (target!.props.productReference) { + const productRef = target!.props.productReference; + const targetReferrers = productRef.getTargetReferrers(); + expect(targetReferrers).toContain(target); + } + }); + + it("should return empty array for non-product files", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "RegularFile.swift", + }); + + const targetReferrers = ref.getTargetReferrers(); + expect(targetReferrers).toEqual([]); + }); + }); + + describe("isAppExtension", () => { + it("should return true for app extension files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyExtension.appex", + lastKnownFileType: "wrapper.app-extension", + sourceTree: "BUILT_PRODUCTS_DIR", + }); + + expect(ref.isAppExtension()).toBe(true); + }); + + it("should return true for ExtensionKit extension files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyExtension.appex", + lastKnownFileType: "wrapper.extensionkit-extension", + sourceTree: "BUILT_PRODUCTS_DIR", + }); + + expect(ref.isAppExtension()).toBe(true); + }); + + it("should return true for explicit file type app extensions", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyExtension.appex", + explicitFileType: "wrapper.app-extension", + sourceTree: "BUILT_PRODUCTS_DIR", + }); + + expect(ref.isAppExtension()).toBe(true); + }); + + it("should return false for app extension files not in BUILT_PRODUCTS_DIR", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyExtension.appex", + lastKnownFileType: "wrapper.app-extension", + sourceTree: "", + }); + + expect(ref.isAppExtension()).toBe(false); + }); + + it("should return false for regular application files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyApp.app", + lastKnownFileType: "wrapper.application", + sourceTree: "BUILT_PRODUCTS_DIR", + }); + + expect(ref.isAppExtension()).toBe(false); + }); + + it("should return false for source files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + lastKnownFileType: "sourcecode.swift", + sourceTree: "", + }); + + expect(ref.isAppExtension()).toBe(false); }); }); - it(`adds json file`, () => { - const ref = PBXFileReference.create(xcproj, { - path: "fun/funky.json", + + describe("proxy and container methods", () => { + it("should return empty array for getProxyContainers on regular files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + const containers = ref.getProxyContainers(); + expect(containers).toEqual([]); + }); + + it("should return empty array for getTargetDependencyProxies on regular files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + const proxies = ref.getTargetDependencyProxies(); + expect(proxies).toEqual([]); }); - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: 0, - isa: "PBXFileReference", - lastKnownFileType: "text.json", - name: "funky.json", - path: "fun/funky.json", - sourceTree: "", + it("should return empty array for getFileReferenceProxies on regular files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "test.swift", + }); + + const proxies = ref.getFileReferenceProxies(); + expect(proxies).toEqual([]); }); }); - it(`adds random file`, () => { - const ref = PBXFileReference.create(xcproj, { - path: "fun/funky", + + describe("removeFromProject", () => { + it("should remove file and all related build files", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + // Create a file reference and add to build phase + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "ToBeRemoved.swift", + }); + + const sourcesPhase = target!.getSourcesBuildPhase(); + const buildFile = sourcesPhase.ensureFile({ fileRef: ref }); + + expect(sourcesPhase.props.files).toContain(buildFile); + expect(mainGroup.props.children).toContain(ref); + + // Store the build file UUID for checking + const buildFileUuid = buildFile.uuid; + + // Remove the file reference + ref.removeFromProject(); + + // Should be removed from group + expect(mainGroup.props.children).not.toContain(ref); + + // Check that build file is removed from the project entirely + expect(xcproj.get(buildFileUuid)).toBeUndefined(); + }); + + it("should remove file without build files", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "UnusedFile.swift", + }); + + expect(mainGroup.props.children).toContain(ref); + + ref.removeFromProject(); + expect(mainGroup.props.children).not.toContain(ref); + }); + }); + + describe("source tree handling", () => { + it("should use SDKROOT for system frameworks", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "System/Library/Frameworks/UIKit.framework", + }); + + expect(ref.props.sourceTree).toBe("SDKROOT"); + }); + + it("should use for regular source files", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "ViewController.swift", + }); + + expect(ref.props.sourceTree).toBe(""); }); - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: 0, - isa: "PBXFileReference", - name: "funky", - path: "fun/funky", - sourceTree: "", + it("should use BUILT_PRODUCTS_DIR for explicit file type", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const ref = PBXFileReference.create(xcproj, { + path: "MyApp.app", + explicitFileType: "wrapper.application", + }); + + expect(ref.props.sourceTree).toBe("BUILT_PRODUCTS_DIR"); }); }); - it(`adds js file`, () => { - const ref = PBXFileReference.create(xcproj, { - path: "fun/funky.js", - }); - - expect(ref.props).toEqual({ - fileEncoding: 4, - includeInIndex: 0, - isa: "PBXFileReference", - lastKnownFileType: "sourcecode.javascript", - name: "funky.js", - path: "fun/funky.js", - sourceTree: "", + + describe("integration tests", () => { + it("should handle complete file lifecycle", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Create file + const ref = mainGroup.createFile({ + path: "CompleteTest.swift", + }); + + // Verify creation + expect(ref.getDisplayName()).toBe("CompleteTest.swift"); + expect(ref.props.lastKnownFileType).toBe("sourcecode.swift"); + expect(ref.getParent()).toBe(mainGroup); + + // Add to build phase + const sourcesPhase = target!.getSourcesBuildPhase(); + sourcesPhase.ensureFile({ fileRef: ref }); + + // Verify it's in build files + expect(ref.getBuildFiles().length).toBeGreaterThan(0); + + // Move to different group + const newGroup = mainGroup.createGroup({ + name: "NewGroup", + sourceTree: "", + }); + ref.move(newGroup); + expect(ref.getParent()).toBe(newGroup); + + // Modify file type + ref.setLastKnownFileType("text.json"); + expect(ref.props.lastKnownFileType).toBe("text.json"); + + // Remove from project + ref.removeFromProject(); + expect(newGroup.props.children).not.toContain(ref); + }); + + it("should work across different project fixtures", () => { + const fixtures = [WORKING_FIXTURE, MULTITARGET_FIXTURE, WATCH_FIXTURE]; + + fixtures.forEach((fixture) => { + const xcproj = XcodeProject.open(fixture); + + // Create a test file in each project + const mainGroup = xcproj.rootObject.props.mainGroup; + const ref = mainGroup.createFile({ + path: "CrossProjectTest.swift", + }); + + // Basic functionality should work + expect(ref.getDisplayName()).toBe("CrossProjectTest.swift"); + expect(ref.props.lastKnownFileType).toBe("sourcecode.swift"); + expect(ref.getParent()).toBe(mainGroup); + expect(typeof ref.isAppExtension()).toBe("boolean"); + expect(Array.isArray(ref.getBuildFiles())).toBe(true); + expect(Array.isArray(ref.getTargetReferrers())).toBe(true); + }); + }); + + it("should handle different file types properly", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const fileTypes = [ + { path: "test.swift", expectedType: "sourcecode.swift" }, + { path: "test.m", expectedType: "sourcecode.c.objc" }, + { path: "test.h", expectedType: "sourcecode.c.h" }, + { path: "test.js", expectedType: "sourcecode.javascript" }, + { path: "test.css", expectedType: "text.css" }, + { path: "test.html", expectedType: "text.html" }, + { path: "test.json", expectedType: "text.json" }, + { path: "TestFramework.framework", expectedType: "wrapper.framework" }, + ]; + + fileTypes.forEach(({ path, expectedType }) => { + const ref = PBXFileReference.create(xcproj, { path }); + expect(ref.props.lastKnownFileType).toBe(expectedType); + }); }); }); }); diff --git a/src/api/__tests__/PBXNativeTarget.test.ts b/src/api/__tests__/PBXNativeTarget.test.ts index f964834..dc27cf8 100644 --- a/src/api/__tests__/PBXNativeTarget.test.ts +++ b/src/api/__tests__/PBXNativeTarget.test.ts @@ -1,37 +1,588 @@ import path from "path"; -import { PBXNativeTarget, XcodeProject } from ".."; +import { + PBXNativeTarget, + XcodeProject, + PBXFrameworksBuildPhase, + PBXCopyFilesBuildPhase, + PBXFileReference, +} from ".."; const WORKING_FIXTURE = path.join( __dirname, "../../json/__tests__/fixtures/AFNetworking.pbxproj" ); -it(`gets referrers`, () => { - const xcproj = XcodeProject.open(WORKING_FIXTURE); - const obj = xcproj.getObject("299522761BBF136400859F49") as PBXNativeTarget; +const MULTITARGET_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/project-multitarget.pbxproj" +); - expect(obj.getReferrers().map((o) => o.uuid)).toEqual([ - "299522301BBF104D00859F49", - ]); -}); +const WATCH_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/watch.pbxproj" +); + +describe("PBXNativeTarget", () => { + describe("basic functionality", () => { + it("gets referrers", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const obj = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + expect(obj.getReferrers().map((o) => o.uuid)).toEqual([ + "299522301BBF104D00859F49", + ]); + }); + + it("sets build setting", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const obj = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + // Sanity + expect(obj.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe( + "8.0" + ); + + expect(obj.setBuildSetting("IPHONEOS_DEPLOYMENT_TARGET", "17.0")).toBe( + "17.0" + ); + + expect(obj.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe( + "17.0" + ); + + obj.removeBuildSetting("IPHONEOS_DEPLOYMENT_TARGET"); + + expect(obj.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe( + undefined + ); + }); + }); + + describe("create", () => { + it("should create new native target with minimal options", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const project = xcproj.rootObject; + const existingTarget = project.props.targets.find((t) => + PBXNativeTarget.is(t) + ) as PBXNativeTarget; + + const newTarget = PBXNativeTarget.create(xcproj, { + name: "TestTarget", + productType: "com.apple.product-type.framework", + buildConfigurationList: existingTarget.props.buildConfigurationList, + }); + + expect(newTarget).toBeDefined(); + expect(newTarget.props.name).toBe("TestTarget"); + expect(newTarget.props.productType).toBe( + "com.apple.product-type.framework" + ); + expect(newTarget.props.buildPhases).toEqual([]); + expect(newTarget.props.buildRules).toEqual([]); + expect(newTarget.props.dependencies).toEqual([]); + }); + }); + + describe("isReferencing", () => { + it("should return true for build rule UUIDs", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + if (target.props.buildRules.length > 0) { + const buildRuleUuid = target.props.buildRules[0].uuid; + expect(target.isReferencing(buildRuleUuid)).toBe(true); + } + }); + + it("should return true for product reference UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + if (target.props.productReference) { + expect(target.isReferencing(target.props.productReference.uuid)).toBe( + true + ); + } + }); + + it("should return false for non-referenced UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + expect(target.isReferencing("NON-EXISTENT-UUID")).toBe(false); + }); + }); + + describe("build phase management", () => { + it("should get or create frameworks build phase", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const frameworksPhase = target.getFrameworksBuildPhase(); + expect(frameworksPhase).toBeDefined(); + expect(PBXFrameworksBuildPhase.is(frameworksPhase)).toBe(true); + + // Should return the same instance on second call + const frameworksPhase2 = target.getFrameworksBuildPhase(); + expect(frameworksPhase2).toBe(frameworksPhase); + }); + + it("should get or create sources build phase", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const sourcesPhase = target.getSourcesBuildPhase(); + expect(sourcesPhase).toBeDefined(); + + // Should return the same instance on second call + const sourcesPhase2 = target.getSourcesBuildPhase(); + expect(sourcesPhase2).toBe(sourcesPhase); + }); + + it("should get or create resources build phase", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const resourcesPhase = target.getResourcesBuildPhase(); + expect(resourcesPhase).toBeDefined(); + + // Should return the same instance on second call + const resourcesPhase2 = target.getResourcesBuildPhase(); + expect(resourcesPhase2).toBe(resourcesPhase); + }); + + it("should get or create headers build phase", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const headersPhase = target.getHeadersBuildPhase(); + expect(headersPhase).toBeDefined(); + + // Should return the same instance on second call + const headersPhase2 = target.getHeadersBuildPhase(); + expect(headersPhase2).toBe(headersPhase); + }); + }); + + describe("ensureFrameworks", () => { + it("should add new frameworks to target", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + const frameworks = ["SwiftUI.framework", "WidgetKit.framework"]; + const buildFiles = target!.ensureFrameworks(frameworks); + + expect(buildFiles).toHaveLength(2); + expect(buildFiles[0]).toBeDefined(); + expect(buildFiles[1]).toBeDefined(); + + // Check that frameworks were added to build phase + const frameworksPhase = target!.getFrameworksBuildPhase(); + expect(frameworksPhase.props.files).toContain(buildFiles[0]); + expect(frameworksPhase.props.files).toContain(buildFiles[1]); + }); + + it("should handle frameworks without .framework extension", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + const frameworks = ["UIKit", "Foundation"]; + const buildFiles = target!.ensureFrameworks(frameworks); + + expect(buildFiles).toHaveLength(2); + + // Check that the file references have the correct names + const frameworksFolder = xcproj.rootObject.getFrameworksGroup(); + const uikitRef = frameworksFolder.props.children.find( + (child) => + PBXFileReference.is(child) && child.props.name === "UIKit.framework" + ); + const foundationRef = frameworksFolder.props.children.find( + (child) => + PBXFileReference.is(child) && + child.props.name === "Foundation.framework" + ); + + expect(uikitRef).toBeDefined(); + expect(foundationRef).toBeDefined(); + }); + + it("should reuse existing framework file references", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + // Add framework first time + const buildFiles1 = target!.ensureFrameworks(["CoreData.framework"]); + + // Add same framework again + const buildFiles2 = target!.ensureFrameworks(["CoreData.framework"]); + + // Should reuse the same file reference + expect(buildFiles1[0].props.fileRef).toBe(buildFiles2[0].props.fileRef); + }); + + it("should add frameworks to Frameworks group", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + const frameworksFolder = xcproj.rootObject.getFrameworksGroup(); + const initialChildCount = frameworksFolder.props.children.length; + + target!.ensureFrameworks(["CloudKit.framework"]); + + // Should have added one more child to Frameworks group + expect(frameworksFolder.props.children.length).toBeGreaterThan( + initialChildCount + ); + + const cloudKitRef = frameworksFolder.props.children.find( + (child) => + PBXFileReference.is(child) && + child.props.name === "CloudKit.framework" + ); + expect(cloudKitRef).toBeDefined(); + }); + }); + + describe("addDependency", () => { + it("should add dependency between targets in same project", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const project = xcproj.rootObject; + const mainTarget = project.getMainAppTarget(); + + // Create a new target to add as dependency (to avoid existing dependencies) + const newTarget = project.createNativeTarget({ + name: "TestDependencyTarget", + productType: "com.apple.product-type.framework", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + const initialDependencyCount = mainTarget!.props.dependencies.length; + + mainTarget!.addDependency(newTarget); + + // Should have added one dependency + expect(mainTarget!.props.dependencies.length).toBe( + initialDependencyCount + 1 + ); + + // Check that the dependency points to the correct target + const newDependency = + mainTarget!.props.dependencies[ + mainTarget!.props.dependencies.length - 1 + ]; + expect(newDependency.props.target).toBe(newTarget); + }); + + it("should not add duplicate dependencies", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + const extensionTarget = xcproj.rootObject.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(mainTarget).toBeDefined(); + expect(extensionTarget).toBeDefined(); + + // Add dependency twice + mainTarget!.addDependency(extensionTarget!); + const dependencyCountAfterFirst = mainTarget!.props.dependencies.length; + + mainTarget!.addDependency(extensionTarget!); + const dependencyCountAfterSecond = mainTarget!.props.dependencies.length; + + // Should not have added a second dependency + expect(dependencyCountAfterSecond).toBe(dependencyCountAfterFirst); + }); + + it("should create container item proxy for dependency", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const project = xcproj.rootObject; + + // Create a new target to add as dependency + const mainTarget = project.getMainAppTarget(); + const newTarget = project.createNativeTarget({ + name: "TestDependency", + productType: "com.apple.product-type.framework", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + mainTarget!.addDependency(newTarget); + + // Check that dependency was created with proper container proxy + const dependency = + mainTarget!.props.dependencies[ + mainTarget!.props.dependencies.length - 1 + ]; + expect(dependency.props.targetProxy).toBeDefined(); + expect(dependency.props.targetProxy.props.remoteGlobalIDString).toBe( + newTarget.uuid + ); + expect(dependency.props.targetProxy.props.remoteInfo).toBe( + "TestDependency" + ); + expect(dependency.props.targetProxy.props.containerPortal).toBe(project); + }); + }); + + describe("getCopyBuildPhaseForTarget", () => { + it("should create copy build phase for app extension", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + const extensionTarget = xcproj.rootObject.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(mainTarget).toBeDefined(); + expect(extensionTarget).toBeDefined(); + + const copyPhase = mainTarget!.getCopyBuildPhaseForTarget( + extensionTarget! + ); + + expect(copyPhase).toBeDefined(); + expect(PBXCopyFilesBuildPhase.is(copyPhase)).toBe(true); + expect(copyPhase.props.name).toBe("Embed Foundation Extensions"); + }); + + it("should create copy build phase for app clips", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + + // Create an app clip target + const appClipTarget = xcproj.rootObject.createNativeTarget({ + name: "TestAppClip", + productType: + "com.apple.product-type.application.on-demand-install-capable", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + const copyPhase = mainTarget!.getCopyBuildPhaseForTarget(appClipTarget); + + expect(copyPhase).toBeDefined(); + expect(copyPhase.props.name).toBe("Embed App Clips"); + }); + + it("should create copy build phase for watch app", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + + // Create a watch app target + const watchTarget = xcproj.rootObject.createNativeTarget({ + name: "TestWatchApp", + productType: "com.apple.product-type.application", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + const copyPhase = mainTarget!.getCopyBuildPhaseForTarget(watchTarget); + + expect(copyPhase).toBeDefined(); + expect(copyPhase.props.name).toBe("Embed Watch Content"); + }); + + it("should create copy build phase for ExtensionKit extensions", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + + // Create an ExtensionKit extension target + const extensionKitTarget = xcproj.rootObject.createNativeTarget({ + name: "TestExtensionKit", + productType: "com.apple.product-type.extensionkit-extension", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + const copyPhase = + mainTarget!.getCopyBuildPhaseForTarget(extensionKitTarget); + + expect(copyPhase).toBeDefined(); + expect(copyPhase.props.name).toBe("Embed ExtensionKit Extensions"); + }); + + it("should reuse existing copy build phase", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + const extensionTarget = xcproj.rootObject.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(mainTarget).toBeDefined(); + expect(extensionTarget).toBeDefined(); + + const copyPhase1 = mainTarget!.getCopyBuildPhaseForTarget( + extensionTarget! + ); + const copyPhase2 = mainTarget!.getCopyBuildPhaseForTarget( + extensionTarget! + ); + + // Should return the same instance + expect(copyPhase1).toBe(copyPhase2); + }); + + it("should throw error when called on non-main target", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const extensionTarget = xcproj.rootObject.getNativeTarget( + "com.apple.product-type.app-extension" + ); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + + expect(extensionTarget).toBeDefined(); + expect(mainTarget).toBeDefined(); + + expect(() => { + extensionTarget!.getCopyBuildPhaseForTarget(mainTarget!); + }).toThrow( + "getCopyBuildPhaseForTarget can only be called on the main target" + ); + }); + }); + + describe("isWatchOSTarget", () => { + it("should return true for watchOS app targets", () => { + const xcproj = XcodeProject.open(WATCH_FIXTURE); + + // Find a target with WATCHOS_DEPLOYMENT_TARGET + let watchTarget: PBXNativeTarget | null = null; + for (const target of xcproj.rootObject.props.targets) { + if ( + PBXNativeTarget.is(target) && + target.props.productType === "com.apple.product-type.application" && + target.getDefaultBuildSetting("WATCHOS_DEPLOYMENT_TARGET") + ) { + watchTarget = target; + break; + } + } + + if (watchTarget) { + expect(watchTarget.isWatchOSTarget()).toBe(true); + } else { + // If no watchOS target found, create one for testing + const xcproj2 = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj2.rootObject.getMainAppTarget(); + const testWatchTarget = xcproj2.rootObject.createNativeTarget({ + name: "TestWatchApp", + productType: "com.apple.product-type.application", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + // Set WATCHOS_DEPLOYMENT_TARGET to make it a watchOS target + testWatchTarget.setBuildSetting("WATCHOS_DEPLOYMENT_TARGET", "8.0"); + + expect(testWatchTarget.isWatchOSTarget()).toBe(true); + } + }); + + it("should return false for iOS app targets", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + + expect(mainTarget).toBeDefined(); + expect(mainTarget!.isWatchOSTarget()).toBe(false); + }); + + it("should return false for non-application targets", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const extensionTarget = xcproj.rootObject.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(extensionTarget).toBeDefined(); + expect(extensionTarget!.isWatchOSTarget()).toBe(false); + }); + + it("should return false for application targets without WATCHOS_DEPLOYMENT_TARGET", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + const testTarget = xcproj.rootObject.createNativeTarget({ + name: "TestApp", + productType: "com.apple.product-type.application", + buildConfigurationList: mainTarget!.props.buildConfigurationList, + }); + + // Ensure no WATCHOS_DEPLOYMENT_TARGET is set + testTarget.removeBuildSetting("WATCHOS_DEPLOYMENT_TARGET"); + + expect(testTarget.isWatchOSTarget()).toBe(false); + }); + }); + + describe("integration tests", () => { + it("should handle complex target relationships", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainTarget = xcproj.rootObject.getMainAppTarget(); + const extensionTarget = xcproj.rootObject.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(mainTarget).toBeDefined(); + expect(extensionTarget).toBeDefined(); + + // Add frameworks to both targets + mainTarget!.ensureFrameworks(["SwiftUI.framework"]); + extensionTarget!.ensureFrameworks(["WidgetKit.framework"]); + + // Add dependency + mainTarget!.addDependency(extensionTarget!); -it(`sets build setting`, () => { - const xcproj = XcodeProject.open(WORKING_FIXTURE); - const obj = xcproj.getObject("299522761BBF136400859F49") as PBXNativeTarget; + // Create copy build phase + const copyPhase = mainTarget!.getCopyBuildPhaseForTarget( + extensionTarget! + ); - // Sanity - expect(obj.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe("8.0"); + // Verify everything is connected properly + expect( + mainTarget!.getFrameworksBuildPhase().props.files.length + ).toBeGreaterThan(0); + expect( + extensionTarget!.getFrameworksBuildPhase().props.files.length + ).toBeGreaterThan(0); + expect(mainTarget!.props.dependencies.length).toBeGreaterThan(0); + expect(copyPhase).toBeDefined(); + }); - expect(obj.setBuildSetting("IPHONEOS_DEPLOYMENT_TARGET", "17.0")).toBe( - "17.0" - ); + it("should work across different project fixtures", () => { + const fixtures = [WORKING_FIXTURE, MULTITARGET_FIXTURE, WATCH_FIXTURE]; - expect(obj.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe("17.0"); + fixtures.forEach((fixture) => { + const xcproj = XcodeProject.open(fixture); - obj.removeBuildSetting("IPHONEOS_DEPLOYMENT_TARGET"); + // Find any native target + const nativeTarget = xcproj.rootObject.props.targets.find((t) => + PBXNativeTarget.is(t) + ) as PBXNativeTarget; - expect(obj.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe( - undefined - ); + if (nativeTarget) { + // Basic functionality should work + expect(nativeTarget.getFrameworksBuildPhase()).toBeDefined(); + expect(nativeTarget.getSourcesBuildPhase()).toBeDefined(); + expect(nativeTarget.getResourcesBuildPhase()).toBeDefined(); + expect(typeof nativeTarget.isWatchOSTarget()).toBe("boolean"); + } + }); + }); + }); }); diff --git a/src/api/__tests__/PBXProject.test.ts b/src/api/__tests__/PBXProject.test.ts index a639f03..24a41fe 100644 --- a/src/api/__tests__/PBXProject.test.ts +++ b/src/api/__tests__/PBXProject.test.ts @@ -1,14 +1,441 @@ import path from "path"; -import { XcodeProject } from ".."; +import { XcodeProject, PBXNativeTarget } from ".."; +import * as json from "../../json/types"; const WORKING_FIXTURE = path.join( __dirname, "../../json/__tests__/fixtures/project-multitarget.pbxproj" ); -it(`gets main app target`, () => { - const xcproj = XcodeProject.open(WORKING_FIXTURE); - const obj = xcproj.rootObject.getMainAppTarget(); - expect(obj?.uuid).toEqual("13B07F861A680F5B00A75B9A"); +const SWIFT_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/project-swift.pbxproj" +); + +const WATCH_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/watch.pbxproj" +); + +const MACOS_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/Cocoa-Application.pbxproj" +); + +describe("PBXProject", () => { + describe("getName", () => { + it("should extract project name from file path", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + // getName() removes .xcodeproj extension, but our fixtures are .pbxproj + expect(project.getName()).toBe("project-multitarget.pbxproj"); + }); + + it("should handle different project names", () => { + const xcproj = XcodeProject.open(SWIFT_FIXTURE); + const project = xcproj.rootObject; + expect(project.getName()).toBe("project-swift.pbxproj"); + }); + }); + + describe("ensureProductGroup", () => { + it("should return existing product group when present", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const productGroup = project.ensureProductGroup(); + + expect(productGroup).toBeDefined(); + expect(productGroup.getDisplayName()).toBe("Products"); + expect(project.props.productRefGroup).toBe(productGroup); + }); + + it("should create product group when missing", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + // Remove existing product group reference + const originalProductGroup = project.props.productRefGroup; + project.props.productRefGroup = undefined; + + const productGroup = project.ensureProductGroup(); + + expect(productGroup).toBeDefined(); + expect(productGroup.getDisplayName()).toBe("Products"); + expect(project.props.productRefGroup).toBe(productGroup); + + // Restore original state + project.props.productRefGroup = originalProductGroup; + }); + }); + + describe("ensureMainGroupChild", () => { + it("should return existing child group", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const frameworksGroup = project.ensureMainGroupChild("Frameworks"); + + expect(frameworksGroup).toBeDefined(); + expect(frameworksGroup.getDisplayName()).toBe("Frameworks"); + }); + + it("should create child group when missing", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const testGroup = project.ensureMainGroupChild("TestGroup"); + + expect(testGroup).toBeDefined(); + expect(testGroup.getDisplayName()).toBe("TestGroup"); + expect(testGroup.props.sourceTree).toBe(""); + }); + }); + + describe("getFrameworksGroup", () => { + it("should return Frameworks group", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const frameworksGroup = project.getFrameworksGroup(); + + expect(frameworksGroup).toBeDefined(); + expect(frameworksGroup.getDisplayName()).toBe("Frameworks"); + }); + }); + + describe("getNativeTarget", () => { + it("should find target by product type", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const appTarget = project.getNativeTarget( + "com.apple.product-type.application" + ); + + expect(appTarget).toBeDefined(); + expect(appTarget?.props.productType).toBe( + "com.apple.product-type.application" + ); + }); + + it("should find app extension target", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const extensionTarget = project.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(extensionTarget).toBeDefined(); + expect(extensionTarget?.props.productType).toBe( + "com.apple.product-type.app-extension" + ); + }); + + it("should return null for non-existent product type", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const target = project.getNativeTarget( + "com.apple.product-type.non-existent" as any + ); + + expect(target).toBeNull(); + }); + }); + + describe("getMainAppTarget", () => { + it("should get main iOS app target", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const target = project.getMainAppTarget("ios"); + + expect(target).toBeDefined(); + expect(target?.uuid).toBe("13B07F861A680F5B00A75B9A"); + expect(target?.props.productType).toBe( + "com.apple.product-type.application" + ); + }); + + it("should default to iOS when no platform specified", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const target = project.getMainAppTarget(); + + expect(target).toBeDefined(); + expect(target?.uuid).toBe("13B07F861A680F5B00A75B9A"); + }); + + it("should handle watchOS projects", () => { + const xcproj = XcodeProject.open(WATCH_FIXTURE); + const project = xcproj.rootObject; + + // The watch fixture has watch app targets (watchapp2 type) + const watchAppTarget = project.getNativeTarget( + "com.apple.product-type.application.watchapp2" as any + ); + expect(watchAppTarget).toBeDefined(); + expect(watchAppTarget?.props.productType).toBe( + "com.apple.product-type.application.watchapp2" + ); + + // Should also have a regular iOS app target + const iosTarget = project.getMainAppTarget("ios"); + expect(iosTarget).toBeDefined(); + }); + + it("should handle macOS projects", () => { + const xcproj = XcodeProject.open(MACOS_FIXTURE); + const project = xcproj.rootObject; + + // Try to get a macOS app target - if it throws, catch and verify behavior + let macTarget; + try { + macTarget = project.getMainAppTarget("macos"); + expect(macTarget).toBeDefined(); + expect(macTarget?.props.productType).toBe( + "com.apple.product-type.application" + ); + } catch (error) { + // This is expected if no macOS deployment target is found + expect((error as Error).message).toBe("No main app target found"); + + // But we should still have regular app targets + const appTarget = project.getNativeTarget( + "com.apple.product-type.application" + ); + expect(appTarget).toBeDefined(); + } + }); + + it("should warn when multiple main app targets found", () => { + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + // Create a duplicate target for testing + const originalTarget = project.getMainAppTarget(); + if (originalTarget) { + const duplicateTarget = project.createNativeTarget({ + name: "DuplicateApp", + productType: "com.apple.product-type.application", + buildConfigurationList: originalTarget.props.buildConfigurationList, + }); + + // Set the same deployment target to make it a main app target + const config = duplicateTarget.getDefaultConfiguration(); + config.props.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = "14.0"; + + project.getMainAppTarget("ios"); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Multiple main app targets found") + ); + + // Clean up + project.props.targets = project.props.targets.filter( + (t) => t !== duplicateTarget + ); + } + + consoleSpy.mockRestore(); + }); + + it("should throw error when no main app target found", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + expect(() => { + project.getMainAppTarget("tvos"); + }).toThrow("No main app target found"); + }); + }); + + describe("createNativeTarget", () => { + it("should create new native target", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const originalTargetCount = project.props.targets.length; + + const existingTarget = project.props.targets.find((t) => + PBXNativeTarget.is(t) + ) as PBXNativeTarget; + const newTarget = project.createNativeTarget({ + name: "TestTarget", + productType: "com.apple.product-type.framework", + buildConfigurationList: existingTarget.props.buildConfigurationList, + }); + + expect(newTarget).toBeDefined(); + expect(newTarget.props.name).toBe("TestTarget"); + expect(newTarget.props.productType).toBe( + "com.apple.product-type.framework" + ); + expect(project.props.targets.length).toBe(originalTargetCount + 1); + expect(project.props.targets).toContain(newTarget); + }); + }); + + describe("isReferencing", () => { + it("should return true for main group UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const mainGroupUuid = project.props.mainGroup.uuid; + + expect(project.isReferencing(mainGroupUuid)).toBe(true); + }); + + it("should return true for build configuration list UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const buildConfigListUuid = project.props.buildConfigurationList.uuid; + + expect(project.isReferencing(buildConfigListUuid)).toBe(true); + }); + + it("should return true for product ref group UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const productRefGroupUuid = project.props.productRefGroup?.uuid; + + if (productRefGroupUuid) { + expect(project.isReferencing(productRefGroupUuid)).toBe(true); + } + }); + + it("should return true for target UUIDs", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const targetUuid = project.props.targets[0].uuid; + + expect(project.isReferencing(targetUuid)).toBe(true); + }); + + it("should return false for non-referenced UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + expect(project.isReferencing("NON-EXISTENT-UUID")).toBe(false); + }); + }); + + describe("addBuildConfiguration", () => { + it("should add new build configuration", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const configList = project.props.buildConfigurationList; + const originalCount = configList.props.buildConfigurations.length; + + const newConfig = project.addBuildConfiguration("CustomConfig", "all"); + + expect(newConfig).toBeDefined(); + expect(newConfig.props.name).toBe("CustomConfig"); + expect(configList.props.buildConfigurations.length).toBe( + originalCount + 1 + ); + expect(configList.props.buildConfigurations).toContain(newConfig); + }); + + it("should return existing configuration if already exists", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + const configList = project.props.buildConfigurationList; + const originalCount = configList.props.buildConfigurations.length; + + // Add a config + const firstConfig = project.addBuildConfiguration( + "ExistingConfig", + "all" + ); + + // Try to add the same config again + const secondConfig = project.addBuildConfiguration( + "ExistingConfig", + "all" + ); + + expect(firstConfig).toBe(secondConfig); + expect(configList.props.buildConfigurations.length).toBe( + originalCount + 1 + ); + }); + + it("should apply correct default build settings for different types", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + const allConfig = project.addBuildConfiguration("AllConfig", "all"); + const releaseConfig = project.addBuildConfiguration( + "ReleaseConfig", + "release" + ); + + // Check that build settings are applied (these come from constants) + expect(allConfig.props.buildSettings).toBeDefined(); + expect(releaseConfig.props.buildSettings).toBeDefined(); + + // Release config should have additional settings beyond "all" + expect( + Object.keys(releaseConfig.props.buildSettings).length + ).toBeGreaterThan(Object.keys(allConfig.props.buildSettings).length); + }); + }); + + describe("setupDefaults", () => { + it("should set default values for new project", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + // These should be set by setupDefaults or exist in the fixture + expect(project.props.compatibilityVersion).toBe("Xcode 3.2"); + expect(project.props.developmentRegion).toBe("en"); + // hasScannedForEncodings can be either 0 or "0" depending on how it's parsed + expect([0, "0"]).toContain(project.props.hasScannedForEncodings); + expect(project.props.knownRegions).toEqual(["en", "Base"]); + expect(project.props.projectDirPath).toBe(""); + expect(project.props.projectRoot).toBe(""); + expect(project.props.attributes).toBeDefined(); + + // The fixture has LastUpgradeCheck but not necessarily LastSwiftUpdateCheck + expect(project.props.attributes.LastUpgradeCheck).toBeDefined(); + expect(project.props.attributes.TargetAttributes).toBeDefined(); + }); + }); + + describe("integration tests", () => { + it("should handle project with multiple targets correctly", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const project = xcproj.rootObject; + + // This fixture has multiple targets + expect(project.props.targets.length).toBeGreaterThan(1); + + // Should be able to find different target types + const appTarget = project.getNativeTarget( + "com.apple.product-type.application" + ); + const extensionTarget = project.getNativeTarget( + "com.apple.product-type.app-extension" + ); + + expect(appTarget).toBeDefined(); + expect(extensionTarget).toBeDefined(); + expect(appTarget).not.toBe(extensionTarget); + }); + + it("should work with different project types", () => { + const fixtures = [WORKING_FIXTURE, SWIFT_FIXTURE, WATCH_FIXTURE]; + + fixtures.forEach((fixture) => { + const xcproj = XcodeProject.open(fixture); + const project = xcproj.rootObject; + + // Basic functionality should work for all projects + expect(project.getName()).toBeTruthy(); + expect(project.props.mainGroup).toBeDefined(); + expect(project.props.buildConfigurationList).toBeDefined(); + expect(project.props.targets.length).toBeGreaterThan(0); + + // Should be able to get frameworks group + const frameworksGroup = project.getFrameworksGroup(); + expect(frameworksGroup).toBeDefined(); + }); + }); + }); });