Skip to content

feat(apple): Add interactive Apple Snapshots wizard#1296

Merged
cameroncooke merged 2 commits into
masterfrom
cam/apple-snapshots-wizard
Jul 1, 2026
Merged

feat(apple): Add interactive Apple Snapshots wizard#1296
cameroncooke merged 2 commits into
masterfrom
cam/apple-snapshots-wizard

Conversation

@cameroncooke

@cameroncooke cameroncooke commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Stack

This is 2/3 in the Apple Snapshots stack replacing the original unified PR #1293.

This PR builds on #1295. The top of the stack is intended to be an exact, lossless re-expression of #1293.

Summary

Adds a standalone Apple Snapshots wizard for configuring Sentry SnapshotPreviews in Apple/Xcode projects without running the normal iOS runtime SDK setup.

The new appleSnapshots integration is available from the wizard picker and via --integration appleSnapshots. It reuses the Xcode project discovery path from #1295, asks for the app target that hosts Swift previews, then selects a hosted XCTest target for running SnapshotPreviews.

This is intentionally separate from the existing ios wizard. Snapshot setup is related to Apple projects, but it should not silently configure normal runtime SDK behavior. Keeping this as its own wizard makes the user-visible behavior narrower: project wiring plus explicit next-step guidance for snapshot export/upload.

What changed

  • Adds the appleSnapshots integration and CLI routing.
  • Adds SnapshotPreviews package configuration.
  • Links:
    • SnapshottingTests to the hosted test target
    • SnapshotPreferences to the selected app target only when Swift previews are detected
  • Creates or reuses a generated SnapshotTest subclass.
  • Ensures the generated test file belongs to the selected XCTest target.
  • Adds scheme inference for generated xcodebuild test guidance only.
  • Prints local export/upload guidance for:
    • xcodebuild
    • sentry-cli snapshots upload
    • existing Fastlane snapshot upload lanes
  • Adds tests for SnapshotPreviews package setup, generated test files, source insertion, smoke coverage, scheme inference, preflight behavior, CLI constants, and run routing.
  • Updates the help snapshot and changelog for the interactive wizard surface.

Behavior contract in this layer

  • App target selection follows the existing interactive wizard pattern.
  • Hosted XCTest target selection uses hosted test target detection from feat(apple): Refactor Xcode project primitives for snapshots #1295.
  • Missing Swift previews are not fatal. Interactive mode asks whether to continue.
  • Snapshot scheme inference is used only to generate xcodebuild test guidance.
  • If the correct scheme cannot be inferred safely, the wizard still completes and prints guidance without a scheme.
  • The wizard does not configure Sentry auth, DSNs, runtime SDK initialization, dSYM upload scripts, Fastlane dSYM setup, MCP config, or CI workflow files.

What this PR intentionally does not do

  • Does not add unattended/non-interactive behavior.
  • Does not add --non-interactive, --app-target, or --hosted-test-target flags for Apple Snapshots.
  • Does not guarantee prompt-free execution for agentic runs.

Those pieces land in #1297.

Suggested review focus

  • SnapshotPreviews package URL, minimum version, and product names.
  • .pbxproj mutation correctness and idempotency for package/product linking.
  • Generated snapshot test file placement/reuse for both classic source phases and synchronized folders.
  • Hosted XCTest target selection behavior in interactive mode.
  • Scheme inference being limited to generated xcodebuild test guidance.
  • Ensuring the existing ios wizard did not pick up accidental runtime behavior changes.

Risk

Medium. The new wizard mutates Xcode project graphs, links package products, and creates/reuses source files. The new appleSnapshots flow is isolated from auth/runtime/dSYM/CI configuration, but unusual Xcode project structures, ambiguous schemes, or non-standard hosted test target settings are the areas most likely to need reviewer scrutiny.

Validation

Before submitting the stack:

Co-Authored-By: GPT-5 Codex noreply@openai.com

cameroncooke commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-xcode-primitives branch from bd6e62e to 11c97d4 Compare June 25, 2026 20:57
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from 1ecaccd to 0e81310 Compare June 25, 2026 20:57
@cameroncooke cameroncooke marked this pull request as ready for review June 25, 2026 21:07

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unsafe managed scheme fallback
    • Restricted managed scheme fallback to only occur when no explicit .xcscheme files exist, preventing incorrect scheme inference when explicit schemes are present but ambiguous.

Create PR

Or push these changes by commenting:

@cursor push 98aa04b94b
Preview (98aa04b94b)
diff --git a/src/apple/snapshots/snapshot-verification-scheme.ts b/src/apple/snapshots/snapshot-verification-scheme.ts
--- a/src/apple/snapshots/snapshot-verification-scheme.ts
+++ b/src/apple/snapshots/snapshot-verification-scheme.ts
@@ -28,9 +28,11 @@
       return matchingExplicitSchemeNames[0];
     }
 
-    const managedSchemeNames = getManagedSchemeNames(xcodeprojPath);
-    if (managedSchemeNames.length === 1) {
-      return managedSchemeNames[0];
+    if (explicitSchemes.length === 0) {
+      const managedSchemeNames = getManagedSchemeNames(xcodeprojPath);
+      if (managedSchemeNames.length === 1) {
+        return managedSchemeNames[0];
+      }
     }
 
     return undefined;

You can send follow-ups to the cloud agent here.

Comment thread src/apple/snapshots/snapshot-verification-scheme.ts Outdated
Comment thread src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from 0e81310 to 7d5069f Compare June 25, 2026 21:34
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-xcode-primitives branch from 11c97d4 to d063349 Compare June 25, 2026 21:34
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from 7d5069f to 1156eb3 Compare June 25, 2026 21:58

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Wrong test class in guidance
    • Modified ensureSnapshotTestFile to extract and return the actual class name from existing files, passed it through the wizard to verification guidance, and updated buildSnapshotExportCommand to use the actual class name instead of generating it.
  • ✅ Fixed: Preferences link failure unreported
    • Changed linked return value in configureSnapshotPreviewsXcodeProject to check that all package products (both SnapshottingTests and SnapshotPreferences) linked successfully using linkResults.every() instead of only checking SnapshottingTests.

Create PR

Or push these changes by commenting:

@cursor push e5faf02297
Preview (e5faf02297)
diff --git a/src/apple/snapshots/apple-snapshots-wizard.ts b/src/apple/snapshots/apple-snapshots-wizard.ts
--- a/src/apple/snapshots/apple-snapshots-wizard.ts
+++ b/src/apple/snapshots/apple-snapshots-wizard.ts
@@ -15,7 +15,10 @@
 import type { XcodeProject } from '../xcode-manager';
 import { checkInstalledCLISnapshots } from './snapshots-cli-preflight';
 import { configureSnapshotPreviewsXcodeProject } from './configure-snapshotpreviews-xcode-project';
-import { ensureSnapshotTestFile } from './snapshot-test-file';
+import {
+  ensureSnapshotTestFile,
+  snapshotTestClassName,
+} from './snapshot-test-file';
 import { resolveSnapshotVerificationSchemeName } from './snapshot-verification-scheme';
 import {
   SNAPSHOTPREVIEWS_MINIMUM_VERSION,
@@ -164,6 +167,9 @@
     verificationGuidance: {
       appId: xcProject.getBundleIdentifierForTarget(appTargetName),
       hostedTestTargetName,
+      snapshotTestClassName:
+        snapshotTestResult.className ??
+        snapshotTestClassName(hostedTestTargetName),
       projectDir,
       projectPath: xcProject.xcodeprojPath,
       schemeName: resolveSnapshotVerificationSchemeName({

diff --git a/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts b/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts
--- a/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts
+++ b/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts
@@ -57,6 +57,6 @@
 
   return {
     changed: linkResults.some((result) => result.changed),
-    linked: snapshottingTestsResult.linked,
+    linked: linkResults.every((result) => result.linked),
   };
 }

diff --git a/src/apple/snapshots/snapshot-test-file.ts b/src/apple/snapshots/snapshot-test-file.ts
--- a/src/apple/snapshots/snapshot-test-file.ts
+++ b/src/apple/snapshots/snapshot-test-file.ts
@@ -12,7 +12,12 @@
 }: {
   xcodeProject: XcodeProject;
   hostedTestTargetName: string;
-}): { changed: boolean; included: boolean; filePath?: string } {
+}): {
+  changed: boolean;
+  included: boolean;
+  filePath?: string;
+  className?: string;
+} {
   const existingSnapshotTestFile = findExistingSnapshotTestFile(
     xcodeProject,
     hostedTestTargetName,
@@ -21,7 +26,8 @@
     return {
       changed: false,
       included: true,
-      filePath: existingSnapshotTestFile,
+      filePath: existingSnapshotTestFile.filePath,
+      className: existingSnapshotTestFile.className,
     };
   }
 
@@ -54,6 +60,7 @@
     changed: fileCreated || membership.changed,
     included: true,
     filePath,
+    className,
   };
 }
 
@@ -89,20 +96,28 @@
 function findExistingSnapshotTestFile(
   xcodeProject: XcodeProject,
   hostedTestTargetName: string,
-): string | undefined {
+): { filePath: string; className: string } | undefined {
   const sourceFiles =
     xcodeProject.getSourceFilesForTarget(hostedTestTargetName);
-  return sourceFiles?.find((filePath) => {
+  for (const filePath of sourceFiles ?? []) {
     if (!filePath.endsWith('.swift') || !fs.existsSync(filePath)) {
-      return false;
+      continue;
     }
 
     const contents = fs.readFileSync(filePath, 'utf8');
-    return (
+    if (
       contents.includes(SNAPSHOT_TEST_IMPORT) &&
       contents.includes(SNAPSHOT_TEST_SUPERCLASS)
-    );
-  });
+    ) {
+      const classMatch = contents.match(
+        /\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*SnapshotTest\b/,
+      );
+      if (classMatch) {
+        return { filePath, className: classMatch[1] };
+      }
+    }
+  }
+  return undefined;
 }
 
 function getSnapshotTestDirectory(

diff --git a/src/apple/snapshots/snapshots-cli-preflight.ts b/src/apple/snapshots/snapshots-cli-preflight.ts
--- a/src/apple/snapshots/snapshots-cli-preflight.ts
+++ b/src/apple/snapshots/snapshots-cli-preflight.ts
@@ -7,11 +7,11 @@
 import * as bash from '../../utils/bash';
 import { checkInstalledCLI } from '../check-installed-cli';
 import * as fastlane from '../fastlane';
-import { snapshotTestClassName } from './snapshot-test-file';
 
 type SnapshotVerificationGuidance = {
   appId?: string;
   hostedTestTargetName: string;
+  snapshotTestClassName: string;
   projectDir: string;
   projectPath: string;
   schemeName?: string;
@@ -130,9 +130,7 @@
     } \\`,
     "  -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \\",
     `  -only-testing:${quoteShell(
-      `${guidance.hostedTestTargetName}/${snapshotTestClassName(
-        guidance.hostedTestTargetName,
-      )}`,
+      `${guidance.hostedTestTargetName}/${guidance.snapshotTestClassName}`,
     )} \\`,
     '  CODE_SIGNING_ALLOWED=NO \\',
     '  | xcpretty',

diff --git a/test/apple/snapshots/apple-snapshots-wizard.test.ts b/test/apple/snapshots/apple-snapshots-wizard.test.ts
--- a/test/apple/snapshots/apple-snapshots-wizard.test.ts
+++ b/test/apple/snapshots/apple-snapshots-wizard.test.ts
@@ -73,9 +73,16 @@
   },
 }));
 
-vi.mock('../../../src/apple/snapshots/snapshot-test-file', () => ({
-  ensureSnapshotTestFile: mocks.ensureSnapshotTestFile,
-}));
+vi.mock('../../../src/apple/snapshots/snapshot-test-file', async (importOriginal) => {
+  const actual =
+    await importOriginal<
+      typeof import('../../../src/apple/snapshots/snapshot-test-file')
+    >();
+  return {
+    ...actual,
+    ensureSnapshotTestFile: mocks.ensureSnapshotTestFile,
+  };
+});
 
 vi.mock(
   '../../../src/apple/snapshots/configure-snapshotpreviews-xcode-project',
@@ -300,6 +307,7 @@
       verificationGuidance: {
         appId: 'com.getsentry.App',
         hostedTestTargetName: 'AppTests',
+        snapshotTestClassName: 'AppTestsSnapshotTest',
         projectDir: tempDir,
         projectPath: path.join(tempDir, 'App.xcodeproj'),
         schemeName: 'AppScheme',

diff --git a/test/apple/snapshots/snapshots-cli-preflight.test.ts b/test/apple/snapshots/snapshots-cli-preflight.test.ts
--- a/test/apple/snapshots/snapshots-cli-preflight.test.ts
+++ b/test/apple/snapshots/snapshots-cli-preflight.test.ts
@@ -52,6 +52,7 @@
   return {
     appId: 'com.getsentry.App',
     hostedTestTargetName: 'AppTests',
+    snapshotTestClassName: 'AppTestsSnapshotTest',
     projectDir,
     projectPath: path.join(projectDir, 'App.xcodeproj'),
     schemeName: 'App',

You can send follow-ups to the cloud agent here.

Comment thread src/apple/snapshots/snapshots-cli-preflight.ts
Comment thread src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts
Comment thread src/apple/snapshots/snapshot-test-file.ts
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch 2 times, most recently from fe68f49 to a291a73 Compare June 25, 2026 22:42
Comment thread src/apple/snapshots/snapshot-test-file.ts
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from a291a73 to a12c102 Compare June 29, 2026 09:29
@philprime philprime self-requested a review June 29, 2026 09:38

@philprime philprime left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some feedback for you to consider

Comment thread src/apple/snapshots/snapshot-verification-scheme.ts Outdated
Comment thread src/apple/snapshots/snapshot-verification-scheme.ts Outdated
Comment thread src/apple/snapshots/apple-snapshots-wizard.ts Outdated
Comment thread src/apple/snapshots/snapshot-verification-scheme.ts Outdated
Comment thread src/apple/snapshots/snapshot-verification-scheme.ts
Comment thread src/apple/snapshots/snapshot-verification-scheme.ts Outdated
Comment thread src/apple/snapshots/apple-snapshots-wizard.ts
Comment thread e2e-tests/tests/pnpm-workspace.test.ts Outdated
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from a12c102 to 842c852 Compare June 29, 2026 13:27

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Existing snapshot path skips validation
    • Removed conditional check so the template is always written when discovery doesn't find a valid snapshot test file, ensuring content consistency.

Create PR

Or push these changes by commenting:

@cursor push b01bff67da
Preview (b01bff67da)
diff --git a/src/apple/snapshots/snapshot-test-file.ts b/src/apple/snapshots/snapshot-test-file.ts
--- a/src/apple/snapshots/snapshot-test-file.ts
+++ b/src/apple/snapshots/snapshot-test-file.ts
@@ -42,9 +42,7 @@
 
   fs.mkdirSync(path.dirname(filePath), { recursive: true });
   const fileCreated = !fs.existsSync(filePath);
-  if (fileCreated) {
-    fs.writeFileSync(filePath, snapshotTestTemplate(className), 'utf8');
-  }
+  fs.writeFileSync(filePath, snapshotTestTemplate(className), 'utf8');
 
   const membership = xcodeProject.addSwiftSourceFileToTarget({
     targetName: hostedTestTargetName,

You can send follow-ups to the cloud agent here.

Comment thread src/apple/snapshots/snapshot-test-file.ts
Comment thread src/apple/snapshots/snapshots-cli-preflight.ts Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Workspace projects get project flag
    • Modified snapshot export command to use -workspace flag instead of -project when project is discovered via .xcworkspace, ensuring correct xcodebuild commands for CocoaPods and other workspace-based setups.

Create PR

Or push these changes by commenting:

@cursor push 71433c66e5
Preview (71433c66e5)
diff --git a/src/apple/lookup-xcode-project.ts b/src/apple/lookup-xcode-project.ts
--- a/src/apple/lookup-xcode-project.ts
+++ b/src/apple/lookup-xcode-project.ts
@@ -17,8 +17,8 @@
   projectDir: string;
 }): Promise<XcodeProject> {
   debug(`Looking for Xcode project in directory: ${chalk.cyan(projectDir)}`);
-  const xcodeProjFiles = searchXcodeProjectAtPath(projectDir);
-  if (xcodeProjFiles.length === 0) {
+  const searchResult = searchXcodeProjectAtPath(projectDir);
+  if (searchResult.projectPaths.length === 0) {
     clack.log.error(
       'No Xcode project found. Please run this command from the root of your project.',
     );
@@ -27,24 +27,24 @@
   }
   debug(
     `Found ${chalk.cyan(
-      xcodeProjFiles.length.toString(),
+      searchResult.projectPaths.length.toString(),
     )} candidates for Xcode project`,
   );
 
   // In case there is only one Xcode project, we can use that one.
   // Otherwise, we need to ask the user which one they want to use.
   let xcodeProjFile: string;
-  if (xcodeProjFiles.length === 1) {
+  if (searchResult.projectPaths.length === 1) {
     debug(`Found exactly one Xcode project, using it`);
     Sentry.setTag('multiple-projects', false);
-    xcodeProjFile = xcodeProjFiles[0];
+    xcodeProjFile = searchResult.projectPaths[0];
   } else {
     debug(`Found multiple Xcode projects, asking user to choose one`);
     Sentry.setTag('multiple-projects', true);
     xcodeProjFile = (
       await traceStep('Choose Xcode project', () =>
         askForItemSelection(
-          xcodeProjFiles,
+          searchResult.projectPaths,
           'Which project do you want to add Sentry to?',
         ),
       )
@@ -59,7 +59,14 @@
     return await abort();
   }
 
-  return new XcodeProject(pathToPbxproj);
+  const xcodeProject = new XcodeProject(pathToPbxproj);
+  if (searchResult.workspacePath) {
+    xcodeProject.xcworkspacePath = path.join(
+      projectDir,
+      searchResult.workspacePath,
+    );
+  }
+  return xcodeProject;
 }
 
 export async function selectXcodeTarget(

diff --git a/src/apple/search-xcode-project-at-path.ts b/src/apple/search-xcode-project-at-path.ts
--- a/src/apple/search-xcode-project-at-path.ts
+++ b/src/apple/search-xcode-project-at-path.ts
@@ -4,20 +4,27 @@
 import { debug } from '../utils/debug';
 import { findFilesWithExtension } from '../utils/find-files-with-extension';
 
-export function searchXcodeProjectAtPath(searchPath: string): string[] {
+export type XcodeProjectSearchResult = {
+  projectPaths: string[];
+  workspacePath?: string;
+};
+
+export function searchXcodeProjectAtPath(
+  searchPath: string,
+): XcodeProjectSearchResult {
   debug('Searching for Xcode project at path: ' + searchPath);
   const projs = findFilesWithExtension(searchPath, '.xcodeproj');
   if (projs.length > 0) {
     debug('Found Xcode project at paths:');
     projs.forEach((proj) => debug('  ' + proj));
-    return projs;
+    return { projectPaths: projs };
   }
 
   debug('Searching for Xcode workspace at path: ' + searchPath);
   const workspace = findFilesWithExtension(searchPath, '.xcworkspace');
   if (workspace.length == 0) {
     debug('No Xcode workspace found at path: ' + searchPath);
-    return [];
+    return { projectPaths: [] };
   }
 
   debug('Found Xcode workspace at path: ' + workspace[0]);
@@ -28,7 +35,7 @@
   );
   if (!fs.existsSync(xsworkspacedata)) {
     debug('No Xcode workspace data found at path: ' + xsworkspacedata);
-    return [];
+    return { projectPaths: [] };
   }
 
   debug('Parsing Xcode workspace data at path: ' + xsworkspacedata);
@@ -51,5 +58,5 @@
 
   debug('Found Xcode project at paths:');
   projs.forEach((proj) => debug('  ' + proj));
-  return projs;
+  return { projectPaths: projs, workspacePath: workspace[0] };
 }

diff --git a/src/apple/snapshots/apple-snapshots-wizard.ts b/src/apple/snapshots/apple-snapshots-wizard.ts
--- a/src/apple/snapshots/apple-snapshots-wizard.ts
+++ b/src/apple/snapshots/apple-snapshots-wizard.ts
@@ -151,6 +151,7 @@
       hostedTestTargetName,
       projectDir,
       projectPath: xcProject.xcodeprojPath,
+      workspacePath: xcProject.xcworkspacePath,
       schemeName: resolveSnapshotVerificationSchemeName({
         hostedTestTargetName,
         xcodeprojPath: xcProject.xcodeprojPath,

diff --git a/src/apple/snapshots/snapshots-cli-preflight.ts b/src/apple/snapshots/snapshots-cli-preflight.ts
--- a/src/apple/snapshots/snapshots-cli-preflight.ts
+++ b/src/apple/snapshots/snapshots-cli-preflight.ts
@@ -13,6 +13,7 @@
   hostedTestTargetName: string;
   projectDir: string;
   projectPath: string;
+  workspacePath?: string;
   schemeName?: string;
   snapshotTestClassName: string;
 };
@@ -117,14 +118,19 @@
 function buildSnapshotExportCommand(
   guidance: SnapshotVerificationGuidance,
 ): string {
+  const projectOrWorkspaceFlag = guidance.workspacePath
+    ? '-workspace'
+    : '-project';
+  const projectOrWorkspacePath = guidance.workspacePath ?? guidance.projectPath;
+  const relativePath =
+    path.relative(guidance.projectDir, projectOrWorkspacePath) ||
+    projectOrWorkspacePath;
+
   return [
     `From ${quoteShell(guidance.projectDir)}, export snapshots with:`,
     'TEST_RUNNER_SNAPSHOTS_EXPORT_DIR="$PWD/snapshot-images" \\',
     'xcodebuild test \\',
-    `  -project ${quoteShell(
-      path.relative(guidance.projectDir, guidance.projectPath) ||
-        guidance.projectPath,
-    )} \\`,
+    `  ${projectOrWorkspaceFlag} ${quoteShell(relativePath)} \\`,
     `  -scheme ${
       guidance.schemeName ? quoteShell(guidance.schemeName) : '<scheme>'
     } \\`,

diff --git a/src/apple/xcode-manager.ts b/src/apple/xcode-manager.ts
--- a/src/apple/xcode-manager.ts
+++ b/src/apple/xcode-manager.ts
@@ -211,6 +211,11 @@
   xcodeprojPath: string;
 
   /**
+   * The path to the `<PROJECT>.xcworkspace` directory, if the project was found via a workspace.
+   */
+  xcworkspacePath?: string;
+
+  /**
    * The path to the `project.pbxproj` file.
    */
   pbxprojPath: string;

diff --git a/test/apple/lookup-xcode-project.test.ts b/test/apple/lookup-xcode-project.test.ts
--- a/test/apple/lookup-xcode-project.test.ts
+++ b/test/apple/lookup-xcode-project.test.ts
@@ -111,7 +111,9 @@
   describe('lookupXcodeProject', () => {
     it('returns an Xcode project without selecting a target', async () => {
       const pbxprojPath = createProject(projectDir, 'App.xcodeproj');
-      mocks.searchXcodeProjectAtPath.mockReturnValue(['App.xcodeproj']);
+      mocks.searchXcodeProjectAtPath.mockReturnValue({
+        projectPaths: ['App.xcodeproj'],
+      });
       mocks.targetsByProjectPath.set(pbxprojPath, ['App']);
 
       const result = await lookupXcodeProject({ projectDir });
@@ -130,10 +132,9 @@
     it('prompts when multiple Xcode projects are found', async () => {
       createProject(projectDir, 'First.xcodeproj');
       const selectedPbxprojPath = createProject(projectDir, 'Second.xcodeproj');
-      mocks.searchXcodeProjectAtPath.mockReturnValue([
-        'First.xcodeproj',
-        'Second.xcodeproj',
-      ]);
+      mocks.searchXcodeProjectAtPath.mockReturnValue({
+        projectPaths: ['First.xcodeproj', 'Second.xcodeproj'],
+      });
       mocks.askForItemSelection.mockResolvedValue({
         value: 'Second.xcodeproj',
       });

diff --git a/test/apple/snapshots/snapshots-cli-preflight.test.ts b/test/apple/snapshots/snapshots-cli-preflight.test.ts
--- a/test/apple/snapshots/snapshots-cli-preflight.test.ts
+++ b/test/apple/snapshots/snapshots-cli-preflight.test.ts
@@ -228,4 +228,24 @@
       hasSentryUploadAction: true,
     });
   });
+
+  it('uses -workspace flag when workspace path is provided', async () => {
+    const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-cli-'));
+    mocks.hasSentryCLI.mockReturnValue(true);
+
+    await checkInstalledCLISnapshots({
+      projectDir,
+      verificationGuidance: {
+        ...getVerificationGuidance(projectDir),
+        workspacePath: path.join(projectDir, 'App.xcworkspace'),
+      },
+    });
+
+    expect(mocks.info).toHaveBeenCalledWith(
+      expect.stringContaining('-workspace App.xcworkspace'),
+    );
+    expect(mocks.info).toHaveBeenCalledWith(
+      expect.stringMatching(/xcodebuild test[^]*-workspace/),
+    );
+  });
 });

You can send follow-ups to the cloud agent here.

Comment thread src/apple/snapshots/snapshots-cli-preflight.ts
Comment thread src/apple/snapshots/snapshot-verification-scheme.ts
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from 3fc21e5 to 2e5797b Compare June 30, 2026 15:05
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-xcode-primitives branch from 1d82e9b to 5a74ab4 Compare June 30, 2026 15:05

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Fastlane guidance always uses ios
    • Modified getSnapshotFastlaneGuidance to detect whether the upload_sentry_snapshots lane is within the iOS platform block and adjusted the printed Fastlane command accordingly.

Create PR

Or push these changes by commenting:

@cursor push e454c5de37
Preview (e454c5de37)
diff --git a/src/apple/snapshots/snapshots-cli-preflight.ts b/src/apple/snapshots/snapshots-cli-preflight.ts
--- a/src/apple/snapshots/snapshots-cli-preflight.ts
+++ b/src/apple/snapshots/snapshots-cli-preflight.ts
@@ -48,9 +48,24 @@
   }
 
   const contents = fs.readFileSync(fastfilePath, 'utf8');
+  const hasUploadLane = /lane\s+:upload_sentry_snapshots\b/.test(contents);
+
+  let isLaneInIOSPlatform = false;
+  if (hasUploadLane) {
+    const iosPlatformMatch = /platform\s+:ios\s+do([\s\S]*?)^end/m.exec(
+      contents,
+    );
+    if (iosPlatformMatch) {
+      isLaneInIOSPlatform = /lane\s+:upload_sentry_snapshots\b/.test(
+        iosPlatformMatch[1],
+      );
+    }
+  }
+
   return {
     fastfilePath,
-    hasUploadLane: /lane\s+:upload_sentry_snapshots\b/.test(contents),
+    hasUploadLane,
+    isLaneInIOSPlatform,
     hasSentryUploadAction: /\bsentry_upload_snapshots\s*\(/.test(contents),
   };
 }
@@ -76,12 +91,16 @@
   verificationGuidance: SnapshotVerificationGuidance;
 }): void {
   if (fastlaneGuidance.fastfilePath && fastlaneGuidance.hasUploadLane) {
+    const fastlaneCommand = fastlaneGuidance.isLaneInIOSPlatform
+      ? 'bundle exec fastlane ios upload_sentry_snapshots path: "$PWD/snapshot-images"'
+      : 'bundle exec fastlane upload_sentry_snapshots path: "$PWD/snapshot-images"';
+
     clack.log.info(
       [
         'Detected existing Fastlane snapshots upload support.',
         buildSnapshotExportCommand(verificationGuidance),
         'Then verify upload with:',
-        'bundle exec fastlane ios upload_sentry_snapshots path: "$PWD/snapshot-images"',
+        fastlaneCommand,
       ].join('\n'),
     );
     return;

diff --git a/test/apple/snapshots/snapshots-cli-preflight.test.ts b/test/apple/snapshots/snapshots-cli-preflight.test.ts
--- a/test/apple/snapshots/snapshots-cli-preflight.test.ts
+++ b/test/apple/snapshots/snapshots-cli-preflight.test.ts
@@ -225,6 +225,7 @@
     expect(getSnapshotFastlaneGuidance(projectDir)).toEqual({
       fastfilePath: path.join(fastlaneDir, 'Fastfile'),
       hasUploadLane: true,
+      isLaneInIOSPlatform: false,
       hasSentryUploadAction: true,
     });
   });

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 2e5797b. Configure here.

Comment thread src/apple/snapshots/snapshots-cli-preflight.ts
Comment thread src/apple/snapshots/snapshots-cli-preflight.ts Outdated

@itaybre itaybre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

cameroncooke commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Merge activity

  • Jul 1, 4:39 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Jul 1, 4:41 PM UTC: Graphite rebased this pull request as part of a merge.
  • Jul 1, 4:46 PM UTC: @cameroncooke merged this pull request with Graphite.

@cameroncooke cameroncooke changed the base branch from cam/apple-snapshots-xcode-primitives to graphite-base/1296 July 1, 2026 16:39
cameroncooke added a commit that referenced this pull request Jul 1, 2026
## Stack

This is **1/3** in the Apple Snapshots stack replacing the original unified PR #1293.

- 1/3: #1295 — Xcode project primitives (this PR)
- 2/3: #1296 — Interactive Apple Snapshots wizard
- 3/3: #1297 — Non-interactive Apple Snapshots mode

The top of the stack is intended to be an exact, lossless re-expression of #1293.

## Summary

This PR refactors the existing Apple/Xcode project infrastructure needed by the Apple Snapshots wizard, without exposing the new `appleSnapshots` wizard yet.

The goal is to make the shared Xcode helpers reviewable independently before adding the user-facing SnapshotPreviews flow. Existing Sentry Apple setup remains routed through the current `ios` wizard; this layer should be shared infrastructure, not a new runtime setup behavior.

## What changed

- Split Xcode project lookup/selection helpers so target discovery can be reused by the snapshots flow.
- Move Sentry Swift package metadata into a reusable package spec.
- Extend `XcodeProject` with reusable helpers for:
  - hosted XCTest target detection via `TEST_HOST`
  - app bundle ID / executable lookup
  - Swift package product linking
  - source file resolution
  - Swift source insertion
  - synchronized-folder source membership support
- Preserve existing `configureXcodeProject` / `updateXcodeProject` behavior for the normal Apple SDK setup path.
- Add focused tests for the refactored project lookup, Xcode mutation, and git helper behavior.

## What this PR intentionally does not do

- Does not add the `appleSnapshots` integration option.
- Does not add the Apple Snapshots wizard entrypoint.
- Does not configure SnapshotPreviews yet.
- Does not configure Sentry auth, DSNs, runtime SDK initialization, dSYM upload scripts, Fastlane setup, MCP config, or CI workflow files.

Those pieces land in the upstack PRs.

## Suggested review focus

- `.pbxproj` mutation correctness and idempotency.
- Shared Swift package product linking behavior.
- Hosted XCTest target detection via `TEST_HOST`.
- Ensuring existing `ios` wizard behavior did not accidentally change.
- Whether the helper boundaries are general enough for both normal Apple SDK setup and SnapshotPreviews setup.

## Risk

Medium. This refactors shared Apple/Xcode project mutation code used by existing setup paths. The risk is primarily around unusual Xcode project graphs, package product linking, target discovery, and source membership handling.

## Validation

Before submitting the stack:

- `yarn test` passed: 947 passed, 4 skipped
- `yarn build` passed
- Final stack tree matched original PR #1293 exactly
- `gt submit --stack --dry-run` passed

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
@cameroncooke cameroncooke changed the base branch from graphite-base/1296 to master July 1, 2026 16:40
cameroncooke and others added 2 commits July 1, 2026 16:40
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Previously the snapshots preflight assumed the Fastfile was readable and
called fs.readFileSync directly, which could throw and abort the wizard
when the file existed but was inaccessible. Wrap the read in a try/catch,
warn the user, and skip Fastlane snapshots upload detection so the wizard
can continue gracefully.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cameroncooke cameroncooke force-pushed the cam/apple-snapshots-wizard branch from c833fc6 to 70d1f2b Compare July 1, 2026 16:40
`${className}.swift`,
);

fs.mkdirSync(path.dirname(filePath), { recursive: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The fs.mkdirSync call in ensureSnapshotTestFile lacks error handling. Filesystem errors like permission issues will cause an unhandled exception, crashing the wizard.
Severity: MEDIUM

Suggested Fix

Wrap the fs.mkdirSync call in a try/catch block. In the catch block, handle the error gracefully. For example, you could clean up any partially created files or directories and inform the user about the filesystem issue instead of crashing the wizard.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/apple/snapshots/snapshot-test-file.ts#L43

Potential issue: The function `ensureSnapshotTestFile` calls `fs.mkdirSync` without a
`try/catch` block. Under certain conditions, such as the process lacking write
permissions, the disk being full, or other filesystem restrictions (EACCES, EPERM,
ENOSPC), this call will throw an exception. Because the exception is not caught locally,
it propagates up to the `withTelemetry` wrapper, which logs the error and then re-throws
it, causing the wizard process to terminate unexpectedly. This results in a crash rather
than a graceful exit with a user-friendly error message.

@cameroncooke cameroncooke merged commit ae8e925 into master Jul 1, 2026
62 checks passed
@cameroncooke cameroncooke deleted the cam/apple-snapshots-wizard branch July 1, 2026 16:46
cameroncooke added a commit that referenced this pull request Jul 1, 2026
## Stack

This is **3/3** in the Apple Snapshots stack replacing the original unified PR #1293.

- 1/3: #1295 — Xcode project primitives
- 2/3: #1296 — Interactive Apple Snapshots wizard
- 3/3: #1297 — Non-interactive Apple Snapshots mode (this PR)

This is the top of the stack. The final tree of this stack was verified to exactly match original PR #1293.

## Summary

Adds unattended/agent-friendly execution for the Apple Snapshots wizard introduced in #1296.

The main goal is that non-interactive mode never waits on stdin. It either proceeds from explicit arguments and safe single-target assumptions, or it fails with actionable manual setup instructions and the CLI flag needed to continue.

## What changed

- Adds Apple Snapshots CLI options:
  - `--non-interactive`
  - `--app-target`
  - `--hosted-test-target`
- Threads non-interactive behavior through:
  - CLI/run routing
  - project lookup
  - git safety checks
  - preview/missing-preview confirmation
  - sentry-cli preflight
  - hosted XCTest target selection
- Adds app-target and hosted-test-target resolution behavior for unattended runs.
- Adds prompt suppression paths so agentic setup cannot hang on stdin.
- Updates help output for the new flags.
- Adds tests for non-interactive success/failure paths, explicit target handling, and prompt-free behavior.

## Behavior contract in this layer

- App target selection uses `--app-target` when provided.
- Without `--app-target`, the wizard auto-selects only when there is a single app target.
- If the app target cannot be resolved safely in non-interactive mode, the wizard fails with guidance to pass `--app-target`.
- Hosted XCTest target selection uses `--hosted-test-target` when provided, as long as the target exists and has a non-empty `TEST_HOST`.
- Without `--hosted-test-target`, the wizard first narrows hosted XCTest targets to those whose `TEST_HOST` matches the selected app target's bundle/executable names.
- If app-specific hosted-test matching finds one target, the wizard uses it.
- If app-specific hosted-test matching finds multiple targets, non-interactive mode fails with guidance to pass `--hosted-test-target`.
- If app-specific hosted-test matching is inconclusive but the project has exactly one hosted XCTest target, the wizard assumes the common case that this target belongs to the selected app.
- If there are multiple hosted XCTest targets and no safe match, non-interactive mode fails with guidance.
- Missing Swift previews are not fatal. Non-interactive mode logs the limitation and continues because agents may still want the project wiring and manual next steps.
- Non-interactive mode must never wait on stdin.

## What this PR intentionally does not do

- Does not configure Sentry auth, DSNs, runtime SDK initialization, dSYM upload scripts, Fastlane dSYM setup, MCP config, or CI workflow files.
- Does not change the existing `ios` wizard into a SnapshotPreviews setup path.
- Does not make unsafe guesses when target resolution is ambiguous.

## Suggested review focus

- The target-selection contract for explicit CLI choices, single-target assumptions, and non-interactive failures.
- Hosted XCTest detection via `TEST_HOST` and app bundle/executable name matching.
- Confirming all non-interactive paths avoid stdin prompts.
- Error messages/manual setup guidance for ambiguous target/project structures.
- Help output and CLI option behavior.

## Risk

Medium. This layer controls unattended behavior and is the most important one for agentic setup safety. The main risk is choosing the wrong app/test target in unusual projects or leaving a prompt path reachable in non-interactive mode.

## Validation

Before submitting the stack:

- `yarn test` passed: 947 passed, 4 skipped
- `yarn build` passed
- Final stack tree matched original PR #1293 exactly
- `gt submit --stack --dry-run` passed

Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants