From edd230fe8df6524ca186e9617463c7ae133995fa Mon Sep 17 00:00:00 2001 From: evanbacon Date: Fri, 27 Feb 2026 12:08:44 -0800 Subject: [PATCH] Add comprehensive test coverage for core API classes - AbstractObject.test.ts: Tests for inflate(), toJSON(), isReferencing(), removeReference() - XcodeProject.test.ts: Expanded with getObject(), createModel(), getReferrers(), toJSON() - RoundTrip.test.ts: Parse -> toJSON -> build -> parse validation for 10 fixtures - SwiftPackage.test.ts: Tests for XCRemoteSwiftPackageReference, XCLocalSwiftPackageReference - AbstractGroup.test.ts: Tests for mkdir(), move(), createGroup(), createFile() - Xcode16Features.test.ts: Tests for PBXFileSystemSynchronizedRootGroup - test-utils.ts: Shared utilities for fixtures, round-trip validation, orphan detection Co-Authored-By: Claude Opus 4.5 --- src/api/__tests__/AbstractGroup.test.ts | 415 ++++++++++++++++++++++ src/api/__tests__/AbstractObject.test.ts | 332 +++++++++++++++++ src/api/__tests__/RoundTrip.test.ts | 283 +++++++++++++++ src/api/__tests__/SwiftPackage.test.ts | 132 +++++++ src/api/__tests__/Xcode16Features.test.ts | 315 ++++++++++++++++ src/api/__tests__/XcodeProject.test.ts | 283 ++++++++++++++- src/api/__tests__/test-utils.ts | 146 ++++++++ 7 files changed, 1905 insertions(+), 1 deletion(-) create mode 100644 src/api/__tests__/AbstractGroup.test.ts create mode 100644 src/api/__tests__/AbstractObject.test.ts create mode 100644 src/api/__tests__/RoundTrip.test.ts create mode 100644 src/api/__tests__/Xcode16Features.test.ts create mode 100644 src/api/__tests__/test-utils.ts diff --git a/src/api/__tests__/AbstractGroup.test.ts b/src/api/__tests__/AbstractGroup.test.ts new file mode 100644 index 0000000..0bf1a62 --- /dev/null +++ b/src/api/__tests__/AbstractGroup.test.ts @@ -0,0 +1,415 @@ +import path from "path"; +import { XcodeProject, PBXGroup, PBXFileReference } from ".."; +import { loadFixture, expectRoundTrip } from "./test-utils"; + +const MULTITARGET_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/project-multitarget.pbxproj" +); + +const originalConsoleWarn = console.warn; +beforeEach(() => { + console.warn = jest.fn(); +}); +afterAll(() => { + console.warn = originalConsoleWarn; +}); + +describe("AbstractGroup", () => { + describe("mkdir", () => { + it("should return self for empty array path", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const result = mainGroup.mkdir([]); + expect(result).toBe(mainGroup); + }); + + it("should return null for empty string path without recursive", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // "" splits to [""], which is not empty, so it tries to find child "" + const result = mainGroup.mkdir(""); + expect(result).toBeNull(); + }); + + it("should find existing child group by name", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Find an existing child group + const existingChild = mainGroup.getChildGroups()[0]; + expect(existingChild).toBeDefined(); + + const displayName = existingChild.getDisplayName(); + const found = mainGroup.mkdir(displayName); + + expect(found).toBe(existingChild); + }); + + it("should return null for non-existing group without recursive", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const result = mainGroup.mkdir("NonExistentGroup"); + expect(result).toBeNull(); + }); + + it("should create missing groups when recursive=true", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const initialChildCount = mainGroup.props.children.length; + + const result = mainGroup.mkdir("NewGroup", { recursive: true }); + + expect(result).toBeDefined(); + expect(PBXGroup.is(result)).toBe(true); + expect(result!.getDisplayName()).toBe("NewGroup"); + expect(mainGroup.props.children.length).toBe(initialChildCount + 1); + }); + + it("should handle deeply nested paths", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const result = mainGroup.mkdir("A/B/C", { recursive: true }); + + expect(result).toBeDefined(); + expect(result!.getDisplayName()).toBe("C"); + + // Verify the hierarchy was created + const groupA = mainGroup + .getChildGroups() + .find((g) => g.getDisplayName() === "A"); + expect(groupA).toBeDefined(); + + const groupB = groupA! + .getChildGroups() + .find((g) => g.getDisplayName() === "B"); + expect(groupB).toBeDefined(); + + const groupC = groupB! + .getChildGroups() + .find((g) => g.getDisplayName() === "C"); + expect(groupC).toBeDefined(); + expect(groupC).toBe(result); + }); + + it("should accept array path", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const result = mainGroup.mkdir(["X", "Y", "Z"], { recursive: true }); + + expect(result).toBeDefined(); + expect(result!.getDisplayName()).toBe("Z"); + }); + + it("should find existing deeply nested group", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // First create the hierarchy + const created = mainGroup.mkdir("Nested/Path/Here", { recursive: true }); + expect(created).toBeDefined(); + + // Then find it again + const found = mainGroup.mkdir("Nested/Path/Here"); + expect(found).toBe(created); + }); + }); + + describe("move", () => { + it("should move file reference to new parent", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Create a file + const file = mainGroup.createFile({ path: "MoveTest.swift" }); + expect( + mainGroup.props.children.find((c) => c.uuid === file.uuid) + ).toBeDefined(); + + // Create a new group to move to + const newParent = mainGroup.createGroup({ path: "NewParent" }); + + // Move the file + PBXGroup.move(file, newParent); + + // Should be removed from old parent + expect( + mainGroup.props.children.find((c) => c.uuid === file.uuid) + ).toBeUndefined(); + + // Should be in new parent + expect( + newParent.props.children.find((c) => c.uuid === file.uuid) + ).toBeDefined(); + }); + + it("should remove from old parent's children", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const file = mainGroup.createFile({ path: "RemoveFromOld.swift" }); + const initialCount = mainGroup.props.children.length; + + const newParent = mainGroup.createGroup({ path: "Target" }); + + PBXGroup.move(file, newParent); + + // mainGroup should have one less child (the file), but +1 for newParent + // Net change: 0, but file should be gone from children + expect( + mainGroup.props.children.filter((c) => c.uuid === file.uuid).length + ).toBe(0); + }); + + it("should add to new parent's children", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const file = mainGroup.createFile({ path: "AddToNew.swift" }); + const newParent = mainGroup.createGroup({ path: "TargetGroup" }); + + const initialNewParentCount = newParent.props.children.length; + + PBXGroup.move(file, newParent); + + expect(newParent.props.children.length).toBe(initialNewParentCount + 1); + expect( + newParent.props.children.find((c) => c.uuid === file.uuid) + ).toBeDefined(); + }); + + it("should throw when moving object to itself", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const group = mainGroup.createGroup({ path: "SelfMove" }); + + expect(() => { + PBXGroup.move(group, group); + }).toThrow("to itself"); + }); + + it("should throw when moving object to its child", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const parent = mainGroup.createGroup({ path: "Parent" }); + const child = parent.createGroup({ path: "Child" }); + + expect(() => { + PBXGroup.move(parent, child); + }).toThrow("to a child object"); + }); + }); + + describe("createGroup", () => { + it("should create a new child group with path", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const initialCount = mainGroup.props.children.length; + + const newGroup = mainGroup.createGroup({ path: "TestGroup" }); + + expect(newGroup).toBeDefined(); + expect(PBXGroup.is(newGroup)).toBe(true); + expect(newGroup.props.path).toBe("TestGroup"); + expect(mainGroup.props.children.length).toBe(initialCount + 1); + expect( + mainGroup.props.children.find((c) => c.uuid === newGroup.uuid) + ).toBeDefined(); + }); + + it("should create a new child group with name", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const newGroup = mainGroup.createGroup({ name: "NamedGroup" }); + + expect(newGroup).toBeDefined(); + expect(newGroup.props.name).toBe("NamedGroup"); + }); + + it("should throw when neither path nor name provided", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + expect(() => { + mainGroup.createGroup({} as any); + }).toThrow("must have a path or name"); + }); + + it("should register group in project", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const newGroup = mainGroup.createGroup({ path: "RegisteredGroup" }); + + expect(xcproj.has(newGroup.uuid)).toBe(true); + }); + }); + + describe("createFile", () => { + it("should create a new file reference", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const initialCount = mainGroup.props.children.length; + + const newFile = mainGroup.createFile({ path: "NewFile.swift" }); + + expect(newFile).toBeDefined(); + expect(PBXFileReference.is(newFile)).toBe(true); + expect(newFile.props.path).toBe("NewFile.swift"); + expect(mainGroup.props.children.length).toBe(initialCount + 1); + }); + + it("should add file to children array", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const newFile = mainGroup.createFile({ path: "AddedFile.m" }); + + expect( + mainGroup.props.children.find((c) => c.uuid === newFile.uuid) + ).toBeDefined(); + }); + + it("should register file in project", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const newFile = mainGroup.createFile({ path: "Registered.h" }); + + expect(xcproj.has(newFile.uuid)).toBe(true); + }); + + it("should accept sourceTree option", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const newFile = mainGroup.createFile({ + path: "SourceRootFile.swift", + sourceTree: "SOURCE_ROOT", + }); + + expect(newFile.props.sourceTree).toBe("SOURCE_ROOT"); + }); + }); + + describe("getChildGroups", () => { + it("should return only PBXGroup children", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const childGroups = mainGroup.getChildGroups(); + + // All returned items should be PBXGroup + for (const child of childGroups) { + expect(PBXGroup.is(child)).toBe(true); + } + }); + + it("should not include file references", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Add a file to ensure there's a non-group child + mainGroup.createFile({ path: "TestFile.swift" }); + + const childGroups = mainGroup.getChildGroups(); + + // No file references should be in the result + for (const child of childGroups) { + expect(child.isa).not.toBe("PBXFileReference"); + } + }); + }); + + describe("getDisplayName", () => { + it("should return name if present", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const namedGroup = mainGroup.createGroup({ name: "DisplayName" }); + + expect(namedGroup.getDisplayName()).toBe("DisplayName"); + }); + + it("should return basename of path if no name", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const pathGroup = mainGroup.createGroup({ path: "some/nested/path" }); + + expect(pathGroup.getDisplayName()).toBe("path"); + }); + + it("should return 'Main Group' for main group", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Main group typically has no name or path + // Its display name should be "Main Group" + expect(mainGroup.getDisplayName()).toBeDefined(); + }); + }); + + describe("getParent", () => { + it("should return parent group", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const childGroup = mainGroup.createGroup({ path: "Child" }); + const parent = childGroup.getParent(); + + expect(parent).toBe(mainGroup); + }); + }); + + describe("getParents", () => { + it("should return chain of parent groups", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const child = mainGroup.mkdir("A/B/C", { recursive: true })!; + const parents = child.getParents(); + + // Should include B, A, mainGroup, and possibly project + expect(parents.length).toBeGreaterThanOrEqual(3); + }); + }); + + describe("round-trip", () => { + it("should preserve group structure through round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Create a nested structure + const nested = mainGroup.mkdir("Test/Nested/Structure", { + recursive: true, + }); + expect(nested).toBeDefined(); + + // Round-trip should preserve it + expectRoundTrip(xcproj); + }); + + it("should preserve files in groups through round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const group = mainGroup.createGroup({ path: "FilesGroup" }); + group.createFile({ path: "File1.swift" }); + group.createFile({ path: "File2.swift" }); + + expectRoundTrip(xcproj); + }); + }); +}); diff --git a/src/api/__tests__/AbstractObject.test.ts b/src/api/__tests__/AbstractObject.test.ts new file mode 100644 index 0000000..564c3b2 --- /dev/null +++ b/src/api/__tests__/AbstractObject.test.ts @@ -0,0 +1,332 @@ +import path from "path"; +import { + XcodeProject, + PBXNativeTarget, + PBXFileReference, + PBXGroup, +} from ".."; +import { loadFixture, captureWarnings } from "./test-utils"; + +const WORKING_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/AFNetworking.pbxproj" +); + +const MALFORMED_FIXTURE = path.join(__dirname, "fixtures/malformed.pbxproj"); + +const MULTITARGET_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/project-multitarget.pbxproj" +); + +describe("AbstractObject", () => { + describe("inflate", () => { + it("should inflate single object references (e.g., buildConfigurationList)", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + // buildConfigurationList should be inflated to an object, not a UUID string + expect(target.props.buildConfigurationList).toBeDefined(); + expect(typeof target.props.buildConfigurationList).toBe("object"); + expect(target.props.buildConfigurationList.uuid).toBeDefined(); + expect(target.props.buildConfigurationList.isa).toBe( + "XCConfigurationList" + ); + }); + + it("should inflate array references (e.g., buildPhases)", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + // buildPhases should be an array of inflated objects + expect(Array.isArray(target.props.buildPhases)).toBe(true); + expect(target.props.buildPhases.length).toBeGreaterThan(0); + + for (const phase of target.props.buildPhases) { + expect(typeof phase).toBe("object"); + expect(phase.uuid).toBeDefined(); + expect(phase.isa).toBeDefined(); + } + }); + + it("should handle orphaned/missing UUIDs with console.warn", () => { + const { warnings, restore } = captureWarnings(); + + try { + XcodeProject.open(MALFORMED_FIXTURE); + + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain("[Malformed Xcode project]"); + expect(warnings[0]).toContain("orphaned reference"); + } finally { + restore(); + } + }); + + it("should skip nullish properties during inflation", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + // productReference might be undefined for some targets + // The key point is that undefined values don't cause errors + expect(() => target.toJSON()).not.toThrow(); + }); + + it("should not re-inflate already-inflated objects", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + // Get the same object twice + const configList1 = target.props.buildConfigurationList; + const configList2 = xcproj.getObject(configList1.uuid); + + // Should be the exact same object instance + expect(configList1).toBe(configList2); + }); + }); + + describe("toJSON", () => { + it("should deflate single object references to UUIDs", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const json = target.toJSON(); + + // buildConfigurationList should be a UUID string, not an object + expect(typeof json.buildConfigurationList).toBe("string"); + expect(json.buildConfigurationList).toMatch(/^[A-F0-9]{24}$/); + }); + + it("should deflate array references to UUID arrays", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const json = target.toJSON(); + + // buildPhases should be an array of UUID strings + expect(Array.isArray(json.buildPhases)).toBe(true); + for (const uuid of json.buildPhases) { + expect(typeof uuid).toBe("string"); + expect(uuid).toMatch(/^[A-F0-9]{24}$/); + } + }); + + it("should preserve isa field", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const json = target.toJSON(); + expect(json.isa).toBe("PBXNativeTarget"); + }); + + it("should round-trip: inflate -> toJSON produces original structure", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // Get original JSON structure from a known target UUID ("AFNetworking OS X") + const targetUuid = "299522761BBF136400859F49"; + const target = xcproj.getObject(targetUuid) as PBXNativeTarget; + + // After inflating, toJSON should produce valid structure + const json = target.toJSON(); + + // Verify structure is serializable + expect(() => JSON.stringify(json)).not.toThrow(); + + // Key fields should be present + expect(json.isa).toBe("PBXNativeTarget"); + expect(json.name).toBe("AFNetworking OS X"); + expect(typeof json.buildConfigurationList).toBe("string"); + expect(Array.isArray(json.buildPhases)).toBe(true); + expect(Array.isArray(json.dependencies)).toBe(true); + }); + }); + + describe("isReferencing", () => { + it("should detect UUID in single reference property", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const configListUuid = target.props.buildConfigurationList.uuid; + expect(target.isReferencing(configListUuid)).toBe(true); + }); + + it("should detect UUID in array reference property", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const firstPhaseUuid = target.props.buildPhases[0].uuid; + expect(target.isReferencing(firstPhaseUuid)).toBe(true); + }); + + it("should return false for unrelated UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + expect(target.isReferencing("NONEXISTENT1234567890AB")).toBe(false); + }); + + it("should return false for UUIDs in non-object props", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + // The target's own UUID is not a "reference" to another object + expect(target.isReferencing(target.uuid)).toBe(false); + }); + }); + + describe("removeReference", () => { + it("should remove UUID from array references", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + const initialPhaseCount = target!.props.buildPhases.length; + expect(initialPhaseCount).toBeGreaterThan(0); + + const phaseToRemove = target!.props.buildPhases[0]; + const phaseUuid = phaseToRemove.uuid; + + target!.removeReference(phaseUuid); + + // Phase should be removed from array + expect(target!.props.buildPhases.length).toBe(initialPhaseCount - 1); + expect( + target!.props.buildPhases.find((p) => p.uuid === phaseUuid) + ).toBeUndefined(); + }); + + it("should set single reference to undefined when matched", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + expect(target!.props.productReference).toBeDefined(); + + const productRefUuid = target!.props.productReference!.uuid; + + target!.removeReference(productRefUuid); + + // Product reference should be undefined + expect(target!.props.productReference).toBeUndefined(); + }); + + it("should not modify references that don't match", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + const initialPhaseCount = target!.props.buildPhases.length; + const configListUuid = target!.props.buildConfigurationList.uuid; + + // Try to remove a non-existent UUID + target!.removeReference("NONEXISTENT1234567890AB"); + + // Nothing should change + expect(target!.props.buildPhases.length).toBe(initialPhaseCount); + expect(target!.props.buildConfigurationList.uuid).toBe(configListUuid); + }); + }); + + describe("removeFromProject", () => { + it("should remove object from project map", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + + // Create a new file reference to remove + const mainGroup = xcproj.rootObject.props.mainGroup; + const newFile = mainGroup.createFile({ path: "test.swift" }); + const newFileUuid = newFile.uuid; + + expect(xcproj.has(newFileUuid)).toBe(true); + + newFile.removeFromProject(); + + expect(xcproj.has(newFileUuid)).toBe(false); + }); + + it("should remove references from all referrers", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + + // Create a file and add it to a group + const mainGroup = xcproj.rootObject.props.mainGroup; + const newFile = mainGroup.createFile({ path: "test2.swift" }); + + // Verify it's in the group + expect( + mainGroup.props.children.find((c) => c.uuid === newFile.uuid) + ).toBeDefined(); + + newFile.removeFromProject(); + + // Should be removed from group's children + expect( + mainGroup.props.children.find((c) => c.uuid === newFile.uuid) + ).toBeUndefined(); + }); + }); + + describe("getReferrers", () => { + it("should find all objects referencing this object", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const referrers = target.getReferrers(); + + // Should have at least the project referencing this target + expect(referrers.length).toBeGreaterThan(0); + + // One referrer should be the PBXProject (targets array) + const projectReferrer = referrers.find((r) => r.isa === "PBXProject"); + expect(projectReferrer).toBeDefined(); + }); + }); + + describe("getDisplayName", () => { + it("should return name prop if available", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + expect(target.getDisplayName()).toBe("AFNetworking OS X"); + }); + + it("should return isa-based name when no name prop", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // Find a build phase (usually doesn't have a name) + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + const sourcePhase = target.props.buildPhases.find( + (p) => p.isa === "PBXSourcesBuildPhase" + ); + + expect(sourcePhase).toBeDefined(); + // PBXSourcesBuildPhase -> "SourcesBuildPhase" + expect(sourcePhase!.getDisplayName()).toBe("SourcesBuildPhase"); + }); + }); +}); diff --git a/src/api/__tests__/RoundTrip.test.ts b/src/api/__tests__/RoundTrip.test.ts new file mode 100644 index 0000000..adfcbe4 --- /dev/null +++ b/src/api/__tests__/RoundTrip.test.ts @@ -0,0 +1,283 @@ +import path from "path"; +import { XcodeProject, PBXNativeTarget } from ".."; +import { build, parse } from "../../json"; +import { + loadFixture, + expectRoundTrip, + expectNoOrphanReferences, +} from "./test-utils"; + +/** + * Round-trip tests ensure that: + * 1. Parsing a pbxproj file and serializing it back produces equivalent data + * 2. The high-level API correctly inflates and deflates object references + * 3. Modifications to the project are preserved through serialization cycles + * + * Following XcodeProj (Swift) patterns for validation. + */ + +const FIXTURES_DIR = path.join(__dirname, "../../json/__tests__/fixtures/"); + +// Fixtures that should round-trip cleanly through the API layer +// Note: 009-expo-app-clip.pbxproj is excluded due to known serialization +// issue with `lastKnownFileType: undefined` becoming `"undefined"` string +const roundTripFixtures = [ + "006-spm.pbxproj", + "007-xcode16.pbxproj", + "AFNetworking.pbxproj", + "project.pbxproj", + "project-rn74.pbxproj", + "project-multitarget.pbxproj", + "project-rni.pbxproj", + "project-swift.pbxproj", + "project-with-entitlements.pbxproj", + "watch.pbxproj", +]; + +const originalConsoleWarn = console.warn; +beforeEach(() => { + console.warn = jest.fn(); +}); +afterAll(() => { + console.warn = originalConsoleWarn; +}); + +describe("Round-trip correctness", () => { + describe("parse -> toJSON -> build -> parse", () => { + roundTripFixtures.forEach((fixture) => { + it(`should round-trip ${fixture} without data loss`, () => { + const filePath = path.join(FIXTURES_DIR, fixture); + const xcproj = XcodeProject.open(filePath); + + // First serialization + const json1 = xcproj.toJSON(); + + // Build back to pbxproj format + const built = build(json1); + + // Parse again + const parsed = parse(built); + + // Create new project from parsed data + const xcproj2 = new XcodeProject(filePath, parsed); + + // Second serialization + const json2 = xcproj2.toJSON(); + + // Compare JSON structures + expect(json2).toEqual(json1); + }); + }); + }); + + describe("API round-trip", () => { + roundTripFixtures.forEach((fixture) => { + it(`should preserve object references in ${fixture}`, () => { + const xcproj = loadFixture(fixture); + + // Inflate all objects by iterating + for (const obj of xcproj.values()) { + // Access to trigger any lazy inflation + obj.toJSON(); + } + + // Verify round-trip + expectRoundTrip(xcproj); + }); + }); + }); + + describe("modification round-trip", () => { + it("should preserve added target after round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const project = xcproj.rootObject; + + // Get existing target for build configuration list + const existingTarget = project.getMainAppTarget(); + expect(existingTarget).toBeDefined(); + + const initialTargetCount = project.props.targets.length; + + // Create a new target + const newTarget = project.createNativeTarget({ + name: "RoundTripTestTarget", + productType: "com.apple.product-type.framework", + buildConfigurationList: existingTarget!.props.buildConfigurationList, + }); + + expect(project.props.targets.length).toBe(initialTargetCount + 1); + + // Round-trip + const json1 = xcproj.toJSON(); + const built = build(json1); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + // Verify new target exists + expect(xcproj2.rootObject.props.targets.length).toBe( + initialTargetCount + 1 + ); + + const foundTarget = xcproj2.rootObject.props.targets.find( + (t) => PBXNativeTarget.is(t) && t.props.name === "RoundTripTestTarget" + ); + expect(foundTarget).toBeDefined(); + }); + + it("should preserve modified build settings after round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const target = xcproj.rootObject.getMainAppTarget(); + expect(target).toBeDefined(); + + // Modify a build setting + const originalValue = target!.getDefaultBuildSetting( + "IPHONEOS_DEPLOYMENT_TARGET" + ); + target!.setBuildSetting("IPHONEOS_DEPLOYMENT_TARGET", "99.0"); + + // Round-trip + const json1 = xcproj.toJSON(); + const built = build(json1); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + // Verify modification persists + const target2 = xcproj2.rootObject.getMainAppTarget(); + expect(target2).toBeDefined(); + expect(target2!.getDefaultBuildSetting("IPHONEOS_DEPLOYMENT_TARGET")).toBe( + "99.0" + ); + }); + + it("should preserve added file reference after round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const mainGroup = xcproj.rootObject.props.mainGroup; + + const initialChildCount = mainGroup.props.children.length; + + // Add a new file + const newFile = mainGroup.createFile({ + path: "RoundTripTest.swift", + sourceTree: "", + }); + + expect(mainGroup.props.children.length).toBe(initialChildCount + 1); + + // Round-trip + const json1 = xcproj.toJSON(); + const built = build(json1); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + // Verify file exists + const mainGroup2 = xcproj2.rootObject.props.mainGroup; + expect(mainGroup2.props.children.length).toBe(initialChildCount + 1); + + const foundFile = mainGroup2.props.children.find( + (c) => c.isa === "PBXFileReference" && c.props.path === "RoundTripTest.swift" + ); + expect(foundFile).toBeDefined(); + }); + + it("should preserve removed object after round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // Add a file first + const newFile = mainGroup.createFile({ + path: "ToBeRemoved.swift", + sourceTree: "", + }); + const newFileUuid = newFile.uuid; + + // Remove the file + newFile.removeFromProject(); + expect(xcproj.has(newFileUuid)).toBe(false); + + // Round-trip + const json1 = xcproj.toJSON(); + const built = build(json1); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + // Verify file is still gone + expect(xcproj2.has(newFileUuid)).toBe(false); + }); + }); + + describe("no orphan references", () => { + roundTripFixtures.forEach((fixture) => { + it(`${fixture} should have no orphan references after round-trip`, () => { + const xcproj = loadFixture(fixture); + + // Round-trip + const json1 = xcproj.toJSON(); + const built = build(json1); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + // Check for orphans + expectNoOrphanReferences(xcproj2); + }); + }); + }); + + describe("deterministic serialization", () => { + it("should produce identical output for multiple serializations", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + + const json1 = xcproj.toJSON(); + const json2 = xcproj.toJSON(); + + expect(json1).toEqual(json2); + }); + + it("should produce identical pbxproj output for multiple builds", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + + const json = xcproj.toJSON(); + const built1 = build(json); + const built2 = build(json); + + expect(built1).toBe(built2); + }); + }); + + describe("object integrity", () => { + it("should maintain correct isa types after round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + + // Collect isa types + const originalIsaMap = new Map(); + for (const [uuid, obj] of xcproj.entries()) { + originalIsaMap.set(uuid, obj.isa); + } + + // Round-trip + const json = xcproj.toJSON(); + const built = build(json); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + // Verify isa types match + for (const [uuid, expectedIsa] of originalIsaMap) { + expect(xcproj2.has(uuid)).toBe(true); + const obj = xcproj2.get(uuid); + expect(obj?.isa).toBe(expectedIsa); + } + }); + + it("should maintain object count after round-trip", () => { + const xcproj = loadFixture("project-multitarget.pbxproj"); + const originalCount = xcproj.size; + + // Round-trip + const json = xcproj.toJSON(); + const built = build(json); + const parsed = parse(built); + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + + expect(xcproj2.size).toBe(originalCount); + }); + }); +}); diff --git a/src/api/__tests__/SwiftPackage.test.ts b/src/api/__tests__/SwiftPackage.test.ts index 88054eb..db1446b 100644 --- a/src/api/__tests__/SwiftPackage.test.ts +++ b/src/api/__tests__/SwiftPackage.test.ts @@ -5,13 +5,24 @@ import { XCLocalSwiftPackageReference, XCRemoteSwiftPackageReference, XCSwiftPackageProductDependency, + PBXNativeTarget, } from ".."; +import { PBXBuildFile } from "../PBXBuildFile"; +import { loadFixture, expectRoundTrip } from "./test-utils"; const SPM_FIXTURE = path.join( __dirname, "../../json/__tests__/fixtures/006-spm.pbxproj" ); +const originalConsoleWarn = console.warn; +beforeEach(() => { + console.warn = jest.fn(); +}); +afterAll(() => { + console.warn = originalConsoleWarn; +}); + describe("XCLocalSwiftPackageReference", () => { describe("create", () => { it("creates with relativePath", () => { @@ -81,6 +92,20 @@ describe("XCRemoteSwiftPackageReference", () => { minimumVersion: "2.5.1", }); }); + + it("should be referenced by PBXProject.packageReferences", () => { + const xcproj = XcodeProject.open(SPM_FIXTURE); + const project = xcproj.rootObject; + + const packageReferences = project.props.packageReferences; + expect(packageReferences).toBeDefined(); + expect(packageReferences!.length).toBeGreaterThan(0); + + const supabaseRef = packageReferences!.find( + (ref) => ref.uuid === "AC9C55BC2BD9246500041977" + ); + expect(supabaseRef).toBeDefined(); + }); }); describe("create", () => { @@ -255,6 +280,36 @@ describe("XCSwiftPackageProductDependency", () => { "https://github.com/supabase/supabase-swift" ); }); + + it("should be referenced by PBXBuildFile via productRef", () => { + const xcproj = XcodeProject.open(SPM_FIXTURE); + + const buildFile = xcproj.getObject( + "AC9C55BE2BD9246500041977" + ) as PBXBuildFile; + + expect(buildFile).toBeDefined(); + expect(buildFile.isa).toBe("PBXBuildFile"); + expect(buildFile.props.productRef).toBeDefined(); + expect(buildFile.props.productRef!.uuid).toBe("AC9C55BD2BD9246500041977"); + }); + + it("should be in target packageProductDependencies", () => { + const xcproj = XcodeProject.open(SPM_FIXTURE); + + const watchTarget = xcproj.getObject( + "DCA0157385AE428CB5B4F71F" + ) as PBXNativeTarget; + + expect(watchTarget).toBeDefined(); + expect(watchTarget.props.packageProductDependencies).toBeDefined(); + expect(watchTarget.props.packageProductDependencies!.length).toBeGreaterThan(0); + + const supabaseDep = watchTarget.props.packageProductDependencies!.find( + (dep) => dep.uuid === "AC9C55BD2BD9246500041977" + ); + expect(supabaseDep).toBeDefined(); + }); }); describe("create", () => { @@ -388,4 +443,81 @@ describe("round-trip serialization", () => { expect(typeof json.package).toBe("string"); expect(json.package).toBe(packageRef.uuid); }); + + it("should round-trip full project correctly", () => { + const xcproj = loadFixture("006-spm.pbxproj"); + expectRoundTrip(xcproj); + }); +}); + +describe("SPM integration", () => { + it("should maintain SPM references through round-trip", () => { + const xcproj = loadFixture("006-spm.pbxproj"); + + const originalPackageRef = xcproj.getObject( + "AC9C55BC2BD9246500041977" + ) as XCRemoteSwiftPackageReference; + const originalUrl = originalPackageRef.props.repositoryURL; + + expectRoundTrip(xcproj); + + const packageRefAfter = xcproj.getObject( + "AC9C55BC2BD9246500041977" + ) as XCRemoteSwiftPackageReference; + expect(packageRefAfter.props.repositoryURL).toBe(originalUrl); + }); + + it("should find all SPM-related objects", () => { + const xcproj = XcodeProject.open(SPM_FIXTURE); + + const remotePackages: XCRemoteSwiftPackageReference[] = []; + const productDeps: XCSwiftPackageProductDependency[] = []; + const buildFilesWithProductRef: PBXBuildFile[] = []; + + for (const obj of xcproj.values()) { + if (XCRemoteSwiftPackageReference.is(obj)) { + remotePackages.push(obj); + } else if (XCSwiftPackageProductDependency.is(obj)) { + productDeps.push(obj); + } else if (obj.isa === "PBXBuildFile") { + const buildFile = obj as PBXBuildFile; + if (buildFile.props.productRef) { + buildFilesWithProductRef.push(buildFile); + } + } + } + + expect(remotePackages.length).toBeGreaterThan(0); + expect(productDeps.length).toBeGreaterThan(0); + expect(buildFilesWithProductRef.length).toBeGreaterThan(0); + }); + + it("should have valid package reference chain", () => { + const xcproj = XcodeProject.open(SPM_FIXTURE); + const project = xcproj.rootObject; + + expect(project.props.packageReferences).toBeDefined(); + + for (const packageRef of project.props.packageReferences!) { + expect( + XCRemoteSwiftPackageReference.is(packageRef) || + XCLocalSwiftPackageReference.is(packageRef) + ).toBe(true); + } + }); + + it("should serialize SPM objects correctly in full project", () => { + const xcproj = XcodeProject.open(SPM_FIXTURE); + const json = xcproj.toJSON(); + + expect(json.objects["AC9C55BC2BD9246500041977"]).toBeDefined(); + expect(json.objects["AC9C55BC2BD9246500041977"].isa).toBe( + "XCRemoteSwiftPackageReference" + ); + + expect(json.objects["AC9C55BD2BD9246500041977"]).toBeDefined(); + expect(json.objects["AC9C55BD2BD9246500041977"].isa).toBe( + "XCSwiftPackageProductDependency" + ); + }); }); diff --git a/src/api/__tests__/Xcode16Features.test.ts b/src/api/__tests__/Xcode16Features.test.ts new file mode 100644 index 0000000..ce859ff --- /dev/null +++ b/src/api/__tests__/Xcode16Features.test.ts @@ -0,0 +1,315 @@ +import path from "path"; +import { XcodeProject, PBXNativeTarget } from ".."; +import { PBXFileSystemSynchronizedRootGroup } from "../PBXFileSystemSynchronizedRootGroup"; +import { PBXFileSystemSynchronizedBuildFileExceptionSet } from "../PBXFileSystemSynchronizedBuildFileExceptionSet"; +import { loadFixture, expectRoundTrip } from "./test-utils"; + +const XCODE16_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/007-xcode16.pbxproj" +); + +const originalConsoleWarn = console.warn; +beforeEach(() => { + console.warn = jest.fn(); +}); +afterAll(() => { + console.warn = originalConsoleWarn; +}); + +describe("Xcode 16 Features", () => { + describe("PBXFileSystemSynchronizedRootGroup", () => { + it("should parse from fixture", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + // Find a file system synchronized root group (Views) + const viewsGroup = xcproj.getObject( + "3E7D82792C3892F2006B36EB" + ) as PBXFileSystemSynchronizedRootGroup; + + expect(viewsGroup).toBeDefined(); + expect(viewsGroup.isa).toBe("PBXFileSystemSynchronizedRootGroup"); + expect(viewsGroup.props.path).toBe("Views"); + }); + + it("should serialize back correctly", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const viewsGroup = xcproj.getObject( + "3E7D82792C3892F2006B36EB" + ) as PBXFileSystemSynchronizedRootGroup; + + const json = viewsGroup.toJSON(); + + expect(json.isa).toBe("PBXFileSystemSynchronizedRootGroup"); + expect(json.path).toBe("Views"); + expect(json.sourceTree).toBe(""); + }); + + it("should have exceptions array with inflated objects", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const viewsGroup = xcproj.getObject( + "3E7D82792C3892F2006B36EB" + ) as PBXFileSystemSynchronizedRootGroup; + + expect(viewsGroup.props.exceptions).toBeDefined(); + expect(viewsGroup.props.exceptions!.length).toBeGreaterThan(0); + + // Exceptions should be inflated objects + for (const exception of viewsGroup.props.exceptions!) { + expect(typeof exception).toBe("object"); + expect(exception.uuid).toBeDefined(); + expect(exception.isa).toBeDefined(); + } + }); + + it("should have correct static is() method", () => { + const mockObj = { isa: "PBXFileSystemSynchronizedRootGroup" }; + expect(PBXFileSystemSynchronizedRootGroup.is(mockObj)).toBe(true); + + const wrongObj = { isa: "PBXGroup" }; + expect(PBXFileSystemSynchronizedRootGroup.is(wrongObj)).toBe(false); + }); + + it("should be referenced by mainGroup children", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const mainGroup = xcproj.rootObject.props.mainGroup; + + // File system synchronized groups should be in the main group hierarchy + let foundSyncGroup = false; + + function findSyncGroups(children: any[]) { + for (const child of children) { + if (PBXFileSystemSynchronizedRootGroup.is(child)) { + foundSyncGroup = true; + return; + } + if (child.props?.children) { + findSyncGroups(child.props.children); + } + } + } + + findSyncGroups(mainGroup.props.children); + expect(foundSyncGroup).toBe(true); + }); + }); + + describe("PBXFileSystemSynchronizedBuildFileExceptionSet", () => { + it("should parse from fixture", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + // Find an exception set + const exceptionSet = xcproj.getObject( + "3E7D827A2C3892FB006B36EB" + ) as PBXFileSystemSynchronizedBuildFileExceptionSet; + + expect(exceptionSet).toBeDefined(); + expect(exceptionSet.isa).toBe( + "PBXFileSystemSynchronizedBuildFileExceptionSet" + ); + }); + + it("should serialize back correctly", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const exceptionSet = xcproj.getObject( + "3E7D827A2C3892FB006B36EB" + ) as PBXFileSystemSynchronizedBuildFileExceptionSet; + + const json = exceptionSet.toJSON(); + + expect(json.isa).toBe("PBXFileSystemSynchronizedBuildFileExceptionSet"); + expect(json.membershipExceptions).toBeDefined(); + expect(Array.isArray(json.membershipExceptions)).toBe(true); + }); + + it("should have membershipExceptions list", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const exceptionSet = xcproj.getObject( + "3E7D827A2C3892FB006B36EB" + ) as PBXFileSystemSynchronizedBuildFileExceptionSet; + + expect(exceptionSet.props.membershipExceptions).toBeDefined(); + expect(exceptionSet.props.membershipExceptions!.length).toBeGreaterThan( + 0 + ); + + // Should include specific Swift files + expect(exceptionSet.props.membershipExceptions).toContain( + "GameListView.swift" + ); + }); + + it("should have inflated target reference", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const exceptionSet = xcproj.getObject( + "3E7D827A2C3892FB006B36EB" + ) as PBXFileSystemSynchronizedBuildFileExceptionSet; + + expect(exceptionSet.props.target).toBeDefined(); + expect(typeof exceptionSet.props.target).toBe("object"); + expect(exceptionSet.props.target.uuid).toBeDefined(); + }); + + it("should have correct static is() method", () => { + const mockObj = { isa: "PBXFileSystemSynchronizedBuildFileExceptionSet" }; + expect(PBXFileSystemSynchronizedBuildFileExceptionSet.is(mockObj)).toBe( + true + ); + + const wrongObj = { isa: "PBXGroup" }; + expect(PBXFileSystemSynchronizedBuildFileExceptionSet.is(wrongObj)).toBe( + false + ); + }); + }); + + describe("exception sets and target relationships", () => { + it("should have target reference pointing to native target", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + // Find the main target + const target = xcproj.getObject( + "3E7D82632C3892C4006B36EB" + ) as PBXNativeTarget; + + expect(target).toBeDefined(); + expect(target.isa).toBe("PBXNativeTarget"); + expect(target.props.name).toBe("ScoreTally"); + }); + + it("should have exception sets referencing the target", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const target = xcproj.getObject( + "3E7D82632C3892C4006B36EB" + ) as PBXNativeTarget; + + // Find exception sets that reference this target + const exceptionSets: PBXFileSystemSynchronizedBuildFileExceptionSet[] = + []; + for (const obj of xcproj.values()) { + if (PBXFileSystemSynchronizedBuildFileExceptionSet.is(obj)) { + if (obj.props.target.uuid === target.uuid) { + exceptionSets.push(obj); + } + } + } + + expect(exceptionSets.length).toBeGreaterThan(0); + }); + }); + + describe("round-trip", () => { + it("should round-trip Xcode 16 features correctly", () => { + const xcproj = loadFixture("007-xcode16.pbxproj"); + expectRoundTrip(xcproj); + }); + + it("should preserve file system synchronized groups", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + // Get original structure + const originalViewsGroup = xcproj.getObject( + "3E7D82792C3892F2006B36EB" + ) as PBXFileSystemSynchronizedRootGroup; + const originalPath = originalViewsGroup.props.path; + const originalExceptionCount = originalViewsGroup.props.exceptions!.length; + + // Round-trip + expectRoundTrip(xcproj); + + // Verify after + const viewsGroup = xcproj.getObject( + "3E7D82792C3892F2006B36EB" + ) as PBXFileSystemSynchronizedRootGroup; + expect(viewsGroup.props.path).toBe(originalPath); + expect(viewsGroup.props.exceptions!.length).toBe(originalExceptionCount); + }); + + it("should preserve exception sets with membership exceptions", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + const exceptionSet = xcproj.getObject( + "3E7D827A2C3892FB006B36EB" + ) as PBXFileSystemSynchronizedBuildFileExceptionSet; + const originalExceptions = [ + ...exceptionSet.props.membershipExceptions!, + ]; + + expectRoundTrip(xcproj); + + const exceptionSetAfter = xcproj.getObject( + "3E7D827A2C3892FB006B36EB" + ) as PBXFileSystemSynchronizedBuildFileExceptionSet; + expect(exceptionSetAfter.props.membershipExceptions).toEqual( + originalExceptions + ); + }); + }); + + describe("integration", () => { + it("should find all Xcode 16 specific object types", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + const syncRootGroups: PBXFileSystemSynchronizedRootGroup[] = []; + const exceptionSets: PBXFileSystemSynchronizedBuildFileExceptionSet[] = []; + + for (const obj of xcproj.values()) { + if (PBXFileSystemSynchronizedRootGroup.is(obj)) { + syncRootGroups.push(obj); + } else if (PBXFileSystemSynchronizedBuildFileExceptionSet.is(obj)) { + exceptionSets.push(obj); + } + } + + // Should have multiple of each + expect(syncRootGroups.length).toBeGreaterThan(0); + expect(exceptionSets.length).toBeGreaterThan(0); + }); + + it("should maintain correct reference chain: groups -> exceptions -> target", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + + const target = xcproj.getObject( + "3E7D82632C3892C4006B36EB" + ) as PBXNativeTarget; + + // Find all file system synchronized root groups + const syncGroups: PBXFileSystemSynchronizedRootGroup[] = []; + for (const obj of xcproj.values()) { + if (PBXFileSystemSynchronizedRootGroup.is(obj)) { + syncGroups.push(obj); + } + } + + expect(syncGroups.length).toBeGreaterThan(0); + + // Each group's exceptions should reference the target + for (const syncGroup of syncGroups) { + if (syncGroup.props.exceptions) { + for (const exception of syncGroup.props.exceptions) { + if (PBXFileSystemSynchronizedBuildFileExceptionSet.is(exception)) { + expect(exception.props.target.uuid).toBe(target.uuid); + } + } + } + } + }); + + it("should serialize all Xcode 16 objects in project toJSON", () => { + const xcproj = XcodeProject.open(XCODE16_FIXTURE); + const json = xcproj.toJSON(); + + // Views group + expect(json.objects["3E7D82792C3892F2006B36EB"]).toBeDefined(); + expect(json.objects["3E7D82792C3892F2006B36EB"].isa).toBe( + "PBXFileSystemSynchronizedRootGroup" + ); + + // Exception set + expect(json.objects["3E7D827A2C3892FB006B36EB"]).toBeDefined(); + expect(json.objects["3E7D827A2C3892FB006B36EB"].isa).toBe( + "PBXFileSystemSynchronizedBuildFileExceptionSet" + ); + }); + }); +}); diff --git a/src/api/__tests__/XcodeProject.test.ts b/src/api/__tests__/XcodeProject.test.ts index d60abc1..2f45c3d 100644 --- a/src/api/__tests__/XcodeProject.test.ts +++ b/src/api/__tests__/XcodeProject.test.ts @@ -1,8 +1,18 @@ import path from "path"; -import { XcodeProject } from ".."; +import { XcodeProject, PBXNativeTarget, PBXGroup } from ".."; +import { build, parse } from "../../json"; +import * as json from "../../json/types"; const MALFORMED_FIXTURE = path.join(__dirname, "fixtures/malformed.pbxproj"); +const WORKING_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/AFNetworking.pbxproj" +); +const MULTITARGET_FIXTURE = path.join( + __dirname, + "../../json/__tests__/fixtures/project-multitarget.pbxproj" +); const originalConsoleWarn = console.warn; beforeEach(() => { @@ -20,6 +30,277 @@ it(`asserts useful error message when malformed`, () => { ); }); +describe("XcodeProject", () => { + describe("getObject", () => { + it("should return cached object for already-inflated UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // Get object twice + const obj1 = xcproj.getObject("299522761BBF136400859F49"); + const obj2 = xcproj.getObject("299522761BBF136400859F49"); + + // Should be exact same instance + expect(obj1).toBe(obj2); + }); + + it("should inflate and cache on first access", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // First access inflates the object + const obj = xcproj.getObject("299522761BBF136400859F49"); + + expect(obj).toBeDefined(); + expect(obj.isa).toBe("PBXNativeTarget"); + expect(xcproj.has("299522761BBF136400859F49")).toBe(true); + }); + + it("should throw for non-existent UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + expect(() => { + xcproj.getObject("NONEXISTENT1234567890AB"); + }).toThrow("object with uuid 'NONEXISTENT1234567890AB' not found"); + }); + }); + + describe("createModel", () => { + it("should generate deterministic UUID from content", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + + // Create two models with same content + const model1 = xcproj.createModel({ + isa: json.ISA.PBXGroup, + children: [], + sourceTree: "", + name: "TestGroup", + }); + + // The UUID should be deterministic - same input gives same UUID pattern + expect(model1.uuid).toBeDefined(); + expect(model1.uuid.length).toBe(24); + }); + + it("should handle UUID collision by producing unique IDs", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + + // Create first model + const model1 = xcproj.createModel({ + isa: json.ISA.PBXGroup, + children: [], + sourceTree: "", + name: "UniqueGroup1", + }); + + // Create another model with same content (would cause collision) + const model2 = xcproj.createModel({ + isa: json.ISA.PBXGroup, + children: [], + sourceTree: "", + name: "UniqueGroup1", + }); + + // UUIDs should be different (collision handled) + expect(model1.uuid).not.toBe(model2.uuid); + }); + + it("should register model in project map", () => { + const xcproj = XcodeProject.open(MULTITARGET_FIXTURE); + + const model = xcproj.createModel({ + isa: json.ISA.PBXGroup, + children: [], + sourceTree: "", + name: "RegisteredGroup", + }); + + expect(xcproj.has(model.uuid)).toBe(true); + expect(xcproj.get(model.uuid)).toBe(model); + }); + }); + + describe("getReferrers", () => { + it("should find all objects referencing a UUID", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const target = xcproj.getObject( + "299522761BBF136400859F49" + ) as PBXNativeTarget; + + const referrers = xcproj.getReferrers(target.uuid); + + // Should include PBXProject (targets array) and possibly PBXTargetDependency + expect(referrers.length).toBeGreaterThan(0); + + const projectReferrer = referrers.find((r) => r.isa === "PBXProject"); + expect(projectReferrer).toBeDefined(); + }); + + it("should return empty array for orphan UUIDs", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + const referrers = xcproj.getReferrers("NONEXISTENT1234567890AB"); + + expect(referrers).toEqual([]); + }); + + it("should find multiple referrers when applicable", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // Find a file reference that might be referenced by multiple build files + const mainGroup = xcproj.rootObject.props.mainGroup; + const fileRef = mainGroup.props.children.find( + (c) => c.isa === "PBXFileReference" + ); + + if (fileRef) { + const referrers = xcproj.getReferrers(fileRef.uuid); + // At minimum, the group should reference it + expect(referrers.length).toBeGreaterThanOrEqual(1); + } + }); + }); + + describe("toJSON", () => { + it("should produce valid JSON structure", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const json = xcproj.toJSON(); + + expect(json.archiveVersion).toBeDefined(); + expect(json.objectVersion).toBeDefined(); + expect(json.rootObject).toBeDefined(); + expect(json.objects).toBeDefined(); + expect(typeof json.rootObject).toBe("string"); + }); + + it("should include all objects in output", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const json = xcproj.toJSON(); + + // All objects in the Map should be in the JSON + for (const uuid of xcproj.keys()) { + expect(json.objects[uuid]).toBeDefined(); + } + }); + + it("should serialize object references as UUIDs", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const json = xcproj.toJSON(); + + // Find a PBXNativeTarget in the output + const targetEntry = Object.entries(json.objects).find( + ([, obj]) => obj.isa === "PBXNativeTarget" + ); + expect(targetEntry).toBeDefined(); + + const [, targetJson] = targetEntry!; + // buildConfigurationList should be a UUID string + const nativeTargetJson = targetJson as any; + expect(typeof nativeTargetJson.buildConfigurationList).toBe("string"); + }); + }); + + describe("toJSON round-trip", () => { + it("should round-trip: parse(build(toJSON())) equals original", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + // First round-trip + const json1 = xcproj.toJSON(); + const built = build(json1); + const parsed = parse(built); + + // Create new project from parsed + const xcproj2 = new XcodeProject(xcproj.filePath, parsed); + const json2 = xcproj2.toJSON(); + + // Should be equal + expect(json2).toEqual(json1); + }); + }); + + describe("rootObject", () => { + it("should be a PBXProject", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + expect(xcproj.rootObject).toBeDefined(); + expect(xcproj.rootObject.isa).toBe("PBXProject"); + }); + + it("should have targets array", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + expect(Array.isArray(xcproj.rootObject.props.targets)).toBe(true); + }); + }); + + describe("getProjectRoot", () => { + it("should return parent of xcodeproj directory", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + const root = xcproj.getProjectRoot(); + + // Should be two levels up from the pbxproj file + // e.g., /path/to/Project.xcodeproj/project.pbxproj -> /path/to + expect(root).not.toContain("project.pbxproj"); + expect(root).not.toContain(".xcodeproj"); + }); + }); + + describe("getReferenceForPath", () => { + it("should throw for non-absolute paths", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + expect(() => { + xcproj.getReferenceForPath("relative/path.swift"); + }).toThrow("Paths must be absolute"); + }); + + it("should return null for non-existent paths", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + const result = xcproj.getReferenceForPath("/nonexistent/path.swift"); + expect(result).toBeNull(); + }); + }); + + describe("Map operations", () => { + it("should support iteration with entries()", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + let count = 0; + for (const [uuid, obj] of xcproj.entries()) { + expect(typeof uuid).toBe("string"); + expect(obj.uuid).toBe(uuid); + count++; + } + + expect(count).toBeGreaterThan(0); + }); + + it("should support values() iteration", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + const values = [...xcproj.values()]; + expect(values.length).toBeGreaterThan(0); + expect(values[0].isa).toBeDefined(); + }); + + it("should support has() method", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + + expect(xcproj.has("299522761BBF136400859F49")).toBe(true); + expect(xcproj.has("NONEXISTENT1234567890AB")).toBe(false); + }); + + it("should support delete() method", () => { + const xcproj = XcodeProject.open(WORKING_FIXTURE); + const uuid = "299522761BBF136400859F49"; + + expect(xcproj.has(uuid)).toBe(true); + xcproj.delete(uuid); + expect(xcproj.has(uuid)).toBe(false); + }); + }); +}); + describe("parse", () => { beforeEach(() => { console.warn = jest.fn(); diff --git a/src/api/__tests__/test-utils.ts b/src/api/__tests__/test-utils.ts new file mode 100644 index 0000000..f13392b --- /dev/null +++ b/src/api/__tests__/test-utils.ts @@ -0,0 +1,146 @@ +import path from "path"; +import { XcodeProject } from ".."; +import { build, parse } from "../../json"; +import { AbstractObject } from "../AbstractObject"; + +/** + * Base fixtures directory + */ +const FIXTURES_DIR = path.join(__dirname, "../../json/__tests__/fixtures/"); + +/** + * Get the absolute path to a fixture file. + */ +export function fixturePath(name: string): string { + return path.join(FIXTURES_DIR, name); +} + +/** + * Load a fixture file and return an XcodeProject instance. + */ +export function loadFixture(name: string): XcodeProject { + return XcodeProject.open(fixturePath(name)); +} + +/** + * Assert that a project round-trips correctly: + * parse -> toJSON -> build -> parse -> toJSON should equal original toJSON + */ +export function expectRoundTrip(project: XcodeProject): void { + const json1 = project.toJSON(); + const built = build(json1); + const parsed = parse(built); + const project2 = new XcodeProject(project.filePath, parsed); + const json2 = project2.toJSON(); + + // Deep equality check + expect(json2).toEqual(json1); +} + +/** + * Assert that no orphan references exist in the project. + * An orphan reference is when an object references a UUID that doesn't exist. + */ +export function expectNoOrphanReferences(project: XcodeProject): void { + const allUuids = new Set(project.keys()); + + for (const [uuid, obj] of project.entries()) { + const json = obj.toJSON(); + + // Check all string values that look like UUIDs (24 hex chars) + checkForOrphanUuids(json, allUuids, uuid, obj.isa); + } +} + +/** + * Recursively check an object for orphan UUID references. + */ +function checkForOrphanUuids( + value: unknown, + validUuids: Set, + parentUuid: string, + parentIsa: string +): void { + if (typeof value === "string") { + // Check if it looks like a UUID (24 hex chars, common in pbxproj) + if (/^[A-F0-9]{24}$/.test(value)) { + if (!validUuids.has(value)) { + throw new Error( + `Orphan reference found: ${parentIsa} (${parentUuid}) references non-existent UUID ${value}` + ); + } + } + } else if (Array.isArray(value)) { + for (const item of value) { + checkForOrphanUuids(item, validUuids, parentUuid, parentIsa); + } + } else if (value && typeof value === "object") { + for (const key of Object.keys(value)) { + // Skip known non-UUID string fields + if ( + key === "isa" || + key === "name" || + key === "path" || + key === "shellScript" || + key === "repositoryURL" + ) { + continue; + } + checkForOrphanUuids( + (value as Record)[key], + validUuids, + parentUuid, + parentIsa + ); + } + } +} + +/** + * Get all objects of a specific type from a project. + */ +export function getObjectsOfType( + project: XcodeProject, + isa: string +): T[] { + const results: T[] = []; + for (const obj of project.values()) { + if (obj.isa === isa) { + results.push(obj as unknown as T); + } + } + return results; +} + +/** + * Silence console.warn during test execution and restore after. + */ +export function withSilentWarnings(fn: () => T): T { + const originalWarn = console.warn; + console.warn = jest.fn(); + try { + return fn(); + } finally { + console.warn = originalWarn; + } +} + +/** + * Create a mock console.warn and return captured warnings. + */ +export function captureWarnings(): { + warnings: string[]; + restore: () => void; +} { + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (...args: unknown[]) => { + warnings.push(args.map(String).join(" ")); + }; + return { + warnings, + restore: () => { + console.warn = originalWarn; + }, + }; +}