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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/api/__tests__/validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { XcodeProject, validateProject, PBXCopyFilesBuildPhase } from "..";
import * as path from "path";

const WATCH_FIXTURE = path.join(
__dirname,
"../../json/__tests__/fixtures/watch.pbxproj"
);

describe("validateProject", () => {
describe("Watch app embedding", () => {
it("should pass validation for correctly configured watch app", () => {
const project = XcodeProject.open(WATCH_FIXTURE);
const issues = validateProject(project);

// The watch fixture should be correctly configured
const watchIssues = issues.filter((i) => i.code.startsWith("WATCH_"));
expect(watchIssues).toHaveLength(0);
});

it("should detect missing Embed Watch Content phase", () => {
const project = XcodeProject.open(WATCH_FIXTURE);

// Remove the embed watch content phase from main target
const mainTarget = project.rootObject.getMainAppTarget("ios")!;
const embedPhase = mainTarget.props.buildPhases.find(
(phase) =>
PBXCopyFilesBuildPhase.is(phase) &&
phase.props.name === "Embed Watch Content"
);

if (embedPhase) {
const idx = mainTarget.props.buildPhases.indexOf(embedPhase);
mainTarget.props.buildPhases.splice(idx, 1);
}

const issues = validateProject(project);
const watchIssues = issues.filter((i) => i.code === "WATCH_EMBED_MISSING");
expect(watchIssues.length).toBeGreaterThan(0);
expect(watchIssues[0].message).toContain("not embedded");
});

it("should detect wrong dstPath", () => {
const project = XcodeProject.open(WATCH_FIXTURE);

// Modify the embed phase to have wrong dstPath
const mainTarget = project.rootObject.getMainAppTarget("ios")!;
const embedPhase = mainTarget.props.buildPhases.find(
(phase) =>
PBXCopyFilesBuildPhase.is(phase) &&
phase.props.name === "Embed Watch Content"
) as PBXCopyFilesBuildPhase;

if (embedPhase) {
// This simulates the misconfiguration
embedPhase.props.dstPath = "";
}

const issues = validateProject(project);
const watchIssues = issues.filter(
(i) => i.code === "WATCH_EMBED_WRONG_PATH"
);
expect(watchIssues.length).toBeGreaterThan(0);
expect(watchIssues[0].message).toContain(
"should be under Watch"
);
});

it("should detect wrong dstSubfolderSpec", () => {
const project = XcodeProject.open(WATCH_FIXTURE);

const mainTarget = project.rootObject.getMainAppTarget("ios")!;
const embedPhase = mainTarget.props.buildPhases.find(
(phase) =>
PBXCopyFilesBuildPhase.is(phase) &&
phase.props.name === "Embed Watch Content"
) as PBXCopyFilesBuildPhase;

if (embedPhase) {
// Set wrong subfolder spec (13 = PlugIns, should be 16 = Products Directory)
embedPhase.props.dstSubfolderSpec = 13;
}

const issues = validateProject(project);
const watchIssues = issues.filter(
(i) => i.code === "WATCH_EMBED_WRONG_SUBFOLDER"
);
expect(watchIssues.length).toBeGreaterThan(0);
expect(watchIssues[0].message).toContain("dstSubfolderSpec");
});
});
});
7 changes: 6 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export { PBXProject } from "./PBXProject";
export { PBXReferenceProxy } from "./PBXReferenceProxy";
export {
AbstractBuildPhase,
AnyBuildPhase,
PBXAppleScriptBuildPhase,
PBXCopyFilesBuildPhase,
PBXFrameworksBuildPhase,
Expand All @@ -24,6 +23,7 @@ export {
PBXShellScriptBuildPhase,
PBXSourcesBuildPhase,
} from "./PBXSourcesBuildPhase";
export type { AnyBuildPhase } from "./PBXSourcesBuildPhase";
export { PBXVariantGroup } from "./PBXVariantGroup";
export { PBXTargetDependency } from "./PBXTargetDependency";
export { AbstractObject } from "./AbstractObject";
Expand All @@ -37,3 +37,8 @@ export { XCScheme, createBuildableReference } from "./XCScheme";
export { XCSharedData } from "./XCSharedData";
export { XCWorkspace } from "./XCWorkspace";
export { IDEWorkspaceChecks } from "./IDEWorkspaceChecks";
export {
validateProject,
type ValidationIssue,
type ValidationSeverity,
} from "./validation";
242 changes: 242 additions & 0 deletions src/api/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import type { XcodeProject } from "./XcodeProject";
import type { PBXNativeTarget } from "./PBXNativeTarget";
import { PBXCopyFilesBuildPhase } from "./PBXSourcesBuildPhase";

export type ValidationSeverity = "error" | "warning";

export interface ValidationIssue {
/** Severity of the issue */
severity: ValidationSeverity;
/** Short identifier for the issue type */
code: string;
/** Human-readable description of the issue */
message: string;
/** The target affected, if applicable */
target?: PBXNativeTarget;
/** Additional context for debugging */
context?: Record<string, unknown>;
}

/**
* Validates an Xcode project and returns any issues found.
*
* This catches common configuration errors that would cause App Store submission failures.
*/
export function validateProject(project: XcodeProject): ValidationIssue[] {
const issues: ValidationIssue[] = [];

issues.push(...validateWatchAppEmbedding(project));
issues.push(...validateAppClipEmbedding(project));

return issues;
}

/**
* Validates that Watch apps are correctly embedded in the main app's Watch/ subdirectory.
*
* Apple requires Watch apps to be placed at `Payload/MainApp.app/Watch/WatchApp.app`.
* This is controlled by a PBXCopyFilesBuildPhase with:
* - dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"
* - dstSubfolderSpec = 16
*/
function validateWatchAppEmbedding(project: XcodeProject): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const rootObject = project.rootObject;

// Find all Watch app targets
const watchTargets = rootObject.props.targets.filter(
(target) =>
"isWatchOSTarget" in target &&
typeof target.isWatchOSTarget === "function" &&
target.isWatchOSTarget()
) as PBXNativeTarget[];

if (watchTargets.length === 0) {
return issues;
}

// Find the main app target (typically the one that embeds watch content)
const mainTarget = rootObject.getMainAppTarget("ios");
if (!mainTarget) {
// Can't validate without a main target
return issues;
}

// Look for "Embed Watch Content" build phase
const embedWatchPhase = mainTarget.props.buildPhases.find(
(phase) =>
PBXCopyFilesBuildPhase.is(phase) &&
phase.props.name === "Embed Watch Content"
) as PBXCopyFilesBuildPhase | undefined;

for (const watchTarget of watchTargets) {
// Check if this watch target's product is in the embed phase
const productRef = watchTarget.props.productReference;
if (!productRef) {
continue;
}

if (!embedWatchPhase) {
issues.push({
severity: "error",
code: "WATCH_EMBED_MISSING",
message: `Watch target "${watchTarget.props.name}" is not embedded. Missing "Embed Watch Content" build phase in main target.`,
target: watchTarget,
context: {
watchTargetUuid: watchTarget.uuid,
mainTargetUuid: mainTarget.uuid,
},
});
continue;
}

// Check if the embed phase has correct settings
if (embedWatchPhase.props.dstPath !== "$(CONTENTS_FOLDER_PATH)/Watch") {
issues.push({
severity: "error",
code: "WATCH_EMBED_WRONG_PATH",
message: `Watch target "${watchTarget.props.name}" embed phase has incorrect dstPath. Expected "$(CONTENTS_FOLDER_PATH)/Watch" but got "${embedWatchPhase.props.dstPath}". This will cause App Store submission to fail with: "The bundle is not contained in a correctly named directory. It should be under Watch."`,
target: watchTarget,
context: {
currentDstPath: embedWatchPhase.props.dstPath,
expectedDstPath: "$(CONTENTS_FOLDER_PATH)/Watch",
},
});
}

if (embedWatchPhase.props.dstSubfolderSpec !== 16) {
issues.push({
severity: "error",
code: "WATCH_EMBED_WRONG_SUBFOLDER",
message: `Watch target "${watchTarget.props.name}" embed phase has incorrect dstSubfolderSpec. Expected 16 (Products Directory) but got ${embedWatchPhase.props.dstSubfolderSpec}.`,
target: watchTarget,
context: {
currentDstSubfolderSpec: embedWatchPhase.props.dstSubfolderSpec,
expectedDstSubfolderSpec: 16,
},
});
}

// Check if the watch app product is actually in the embed files
const isEmbedded = embedWatchPhase.props.files.some(
(file) => file.props.fileRef?.uuid === productRef.uuid
);

if (!isEmbedded) {
issues.push({
severity: "error",
code: "WATCH_NOT_IN_EMBED",
message: `Watch target "${watchTarget.props.name}" product is not included in the "Embed Watch Content" build phase.`,
target: watchTarget,
context: {
productRefUuid: productRef.uuid,
embedPhaseFiles: embedWatchPhase.props.files.map((f) => f.uuid),
},
});
}
}

return issues;
}

/**
* Validates that App Clips are correctly embedded in the main app's AppClips/ subdirectory.
*
* Apple requires App Clips to be placed at `Payload/MainApp.app/AppClips/AppClip.app`.
* This is controlled by a PBXCopyFilesBuildPhase with:
* - dstPath = "$(CONTENTS_FOLDER_PATH)/AppClips"
* - dstSubfolderSpec = 16
*/
function validateAppClipEmbedding(project: XcodeProject): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const rootObject = project.rootObject;

// Find all App Clip targets
const appClipTargets = rootObject.props.targets.filter(
(target) =>
"props" in target &&
target.props.productType ===
"com.apple.product-type.application.on-demand-install-capable"
) as PBXNativeTarget[];

if (appClipTargets.length === 0) {
return issues;
}

// Find the main app target
const mainTarget = rootObject.getMainAppTarget("ios");
if (!mainTarget) {
return issues;
}

// Look for "Embed App Clips" build phase
const embedAppClipPhase = mainTarget.props.buildPhases.find(
(phase) =>
PBXCopyFilesBuildPhase.is(phase) && phase.props.name === "Embed App Clips"
) as PBXCopyFilesBuildPhase | undefined;

for (const appClipTarget of appClipTargets) {
const productRef = appClipTarget.props.productReference;
if (!productRef) {
continue;
}

if (!embedAppClipPhase) {
issues.push({
severity: "error",
code: "APPCLIP_EMBED_MISSING",
message: `App Clip target "${appClipTarget.props.name}" is not embedded. Missing "Embed App Clips" build phase in main target.`,
target: appClipTarget,
context: {
appClipTargetUuid: appClipTarget.uuid,
mainTargetUuid: mainTarget.uuid,
},
});
continue;
}

if (embedAppClipPhase.props.dstPath !== "$(CONTENTS_FOLDER_PATH)/AppClips") {
issues.push({
severity: "error",
code: "APPCLIP_EMBED_WRONG_PATH",
message: `App Clip target "${appClipTarget.props.name}" embed phase has incorrect dstPath. Expected "$(CONTENTS_FOLDER_PATH)/AppClips" but got "${embedAppClipPhase.props.dstPath}".`,
target: appClipTarget,
context: {
currentDstPath: embedAppClipPhase.props.dstPath,
expectedDstPath: "$(CONTENTS_FOLDER_PATH)/AppClips",
},
});
}

if (embedAppClipPhase.props.dstSubfolderSpec !== 16) {
issues.push({
severity: "error",
code: "APPCLIP_EMBED_WRONG_SUBFOLDER",
message: `App Clip target "${appClipTarget.props.name}" embed phase has incorrect dstSubfolderSpec. Expected 16 (Products Directory) but got ${embedAppClipPhase.props.dstSubfolderSpec}.`,
target: appClipTarget,
context: {
currentDstSubfolderSpec: embedAppClipPhase.props.dstSubfolderSpec,
expectedDstSubfolderSpec: 16,
},
});
}

const isEmbedded = embedAppClipPhase.props.files.some(
(file) => file.props.fileRef?.uuid === productRef.uuid
);

if (!isEmbedded) {
issues.push({
severity: "error",
code: "APPCLIP_NOT_IN_EMBED",
message: `App Clip target "${appClipTarget.props.name}" product is not included in the "Embed App Clips" build phase.`,
target: appClipTarget,
context: {
productRefUuid: productRef.uuid,
},
});
}
}

return issues;
}
Loading