Skip to content

Commit ae8e925

Browse files
cameroncookecodex
andauthored
feat(apple): Add interactive Apple Snapshots wizard (#1296)
## Stack This is **2/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 (this PR) - 3/3: #1297 — Non-interactive Apple Snapshots mode 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 #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: - `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>
1 parent ba4e194 commit ae8e925

21 files changed

Lines changed: 2481 additions & 51 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
### Features
1313

14+
- feat(apple): Add Apple Snapshots wizard for SnapshotPreviews Xcode setup
1415
- feat(react-router): Use the stabilized instrumentation API (`createSentryServerInstrumentation` + `reactRouterTracingIntegration().clientInstrumentation`) instead of the experimental `useInstrumentationAPI` flag
1516
- feat(react-router): Use `sentryOnError` on `HydratedRouter` instead of mutating `root.tsx` ErrorBoundary
1617

bin.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const PRESELECTED_PROJECT_OPTIONS: Record<string, yargs.Options> = {
6363
const xcodeProjectDirOption: yargs.Options = {
6464
default: undefined,
6565
describe:
66-
'Path to the project containing the Xcode project file. Only applies to the Apple wizard.',
66+
'Path to the project containing the Xcode project file. Applies to the Apple and Apple Snapshots wizards.',
6767
type: 'string',
6868
// This is a hidden option because it is used as an internal option
6969
hidden: true,

e2e-tests/tests/help-message.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ describe('--help command', () => {
2929
env: SENTRY_WIZARD_QUIET [boolean] [default: false]
3030
-i, --integration Choose the integration to setup
3131
env: SENTRY_WIZARD_INTEGRATION
32-
[choices: "reactNative", "flutter", "ios", "android", "cordova", "angular",
33-
"cloudflare", "electron", "nextjs", "nuxt", "remix", "reactRouter",
34-
"sveltekit", "sourcemaps"]
32+
[choices: "reactNative", "flutter", "ios", "appleSnapshots", "android",
33+
"cordova", "angular", "cloudflare", "electron", "nextjs", "nuxt", "remix",
34+
"reactRouter", "sveltekit", "sourcemaps"]
3535
-p, --platform Choose platform(s)
3636
env: SENTRY_WIZARD_PLATFORM
3737
[array] [choices: "ios", "android"]

lib/Constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export enum Integration {
33
reactNative = 'reactNative',
44
flutter = 'flutter',
55
ios = 'ios',
6+
appleSnapshots = 'appleSnapshots',
67
android = 'android',
78
cordova = 'cordova',
89
angular = 'angular',
@@ -67,6 +68,8 @@ export function getIntegrationDescription(type: string): string {
6768
return 'Configure Source Maps Upload';
6869
case Integration.ios:
6970
return 'iOS';
71+
case Integration.appleSnapshots:
72+
return 'Apple Snapshots';
7073
case Integration.cloudflare:
7174
return 'Cloudflare';
7275
default:
@@ -102,6 +105,8 @@ export function mapIntegrationToPlatform(type: string): string | undefined {
102105
return 'node-cloudflare-workers';
103106
case Integration.ios:
104107
return 'iOS';
108+
case Integration.appleSnapshots:
109+
return 'iOS';
105110
default:
106111
throw new Error(`Unknown integration ${type}`);
107112
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
4+
// @ts-expect-error - clack is ESM and TS complains about that. It works though
5+
import clack from '@clack/prompts';
6+
7+
import { withTelemetry } from '../../telemetry';
8+
import {
9+
confirmContinueIfNoOrDirtyGitRepo,
10+
printWelcome,
11+
} from '../../utils/clack';
12+
import { lookupXcodeProject, selectXcodeTarget } from '../lookup-xcode-project';
13+
import type { AppleWizardOptions } from '../options';
14+
import { checkInstalledCLISnapshots } from './snapshots-cli-preflight';
15+
import { configureSnapshotPreviewsXcodeProject } from './configure-snapshotpreviews-xcode-project';
16+
import { ensureSnapshotTestFile } from './snapshot-test-file';
17+
import { resolveSnapshotVerificationSchemeName } from './snapshot-verification-scheme';
18+
import {
19+
SNAPSHOTPREVIEWS_MINIMUM_VERSION,
20+
SNAPSHOTPREVIEWS_PACKAGE_URL,
21+
SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT,
22+
SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT,
23+
} from './snapshotpreviews-package';
24+
25+
export async function runAppleSnapshotsWizard(
26+
options: AppleWizardOptions,
27+
): Promise<void> {
28+
return withTelemetry(
29+
{
30+
enabled: options.telemetryEnabled,
31+
integration: 'appleSnapshots',
32+
wizardOptions: options,
33+
},
34+
() => runAppleSnapshotsWizardWithTelemetry(options),
35+
);
36+
}
37+
38+
async function runAppleSnapshotsWizardWithTelemetry(
39+
options: AppleWizardOptions,
40+
): Promise<void> {
41+
const projectDir = options.projectDir ?? process.cwd();
42+
43+
printWelcome({
44+
wizardName: 'Sentry Apple Snapshots Wizard',
45+
promoCode: options.promoCode,
46+
});
47+
48+
await confirmContinueIfNoOrDirtyGitRepo({
49+
ignoreGitChanges: options.ignoreGitChanges,
50+
cwd: projectDir,
51+
});
52+
53+
clack.log.info(
54+
[
55+
`Apple Snapshots setup will use ${SNAPSHOTPREVIEWS_PACKAGE_URL}`,
56+
`${SNAPSHOTPREVIEWS_MINIMUM_VERSION}+ and link`,
57+
`${SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT} to the hosted XCTest`,
58+
`target and ${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} to the selected`,
59+
'app target.',
60+
].join(' '),
61+
);
62+
63+
const xcProject = await lookupXcodeProject({ projectDir });
64+
65+
const appTargetName = await selectXcodeTarget(xcProject, {
66+
noTargetMessage: 'No application target found.',
67+
promptMessage: 'Which app target should SnapshotPreviews use?',
68+
});
69+
70+
const hostedTestTargetNames =
71+
xcProject.getHostedUnitTestTargetNamesForApplicationTarget(appTargetName);
72+
const hostedTestTargetName = await selectXcodeTarget(xcProject, {
73+
targetNames: hostedTestTargetNames,
74+
noTargetMessage: [
75+
`No hosted unit-test target was found for ${appTargetName}.`,
76+
'Please configure a unit-test target with TEST_HOST pointing at that app target, then run the wizard again.',
77+
].join(' '),
78+
promptMessage: 'Which test target should render SnapshotPreviews?',
79+
});
80+
81+
const previewTargetNames = [appTargetName];
82+
83+
clack.log.info(
84+
[
85+
`${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} will be linked to the selected`,
86+
'app target when possible. Import SnapshotPreferences in Swift',
87+
'preview files only if you want to use SnapshotPreviews modifiers.',
88+
].join(' '),
89+
);
90+
91+
if (fs.existsSync(path.join(projectDir, 'Package.swift'))) {
92+
clack.log.info(
93+
[
94+
'Detected Package.swift. This wizard does not edit SwiftPM manifests.',
95+
`If SwiftPM preview targets use SnapshotPreferences modifiers, add`,
96+
`the ${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} product dependency to those`,
97+
'targets in Package.swift.',
98+
].join(' '),
99+
);
100+
}
101+
102+
const packageResult = configureSnapshotPreviewsXcodeProject({
103+
xcodeProject: xcProject,
104+
hostedTestTargetName,
105+
previewTargetNames,
106+
});
107+
108+
if (!packageResult.linked) {
109+
clack.log.error(
110+
'SnapshotPreviews package products could not be linked to the selected targets. Please check the Xcode project target build phases and try again.',
111+
);
112+
clack.outro('Apple Snapshots setup did not complete.');
113+
return;
114+
}
115+
116+
if (packageResult.failedSnapshotPreferencesTargetNames.length) {
117+
clack.log.warn(
118+
[
119+
`${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} could not be linked to`,
120+
packageResult.failedSnapshotPreferencesTargetNames.join(', '),
121+
'because the target Frameworks build phase could not be updated.',
122+
'Apple Snapshots setup will continue; link the product manually if',
123+
'you want to use SnapshotPreviews modifiers in Swift previews.',
124+
].join(' '),
125+
);
126+
}
127+
128+
const snapshotTestResult = ensureSnapshotTestFile({
129+
xcodeProject: xcProject,
130+
hostedTestTargetName,
131+
});
132+
if (!snapshotTestResult.included) {
133+
clack.log.error(
134+
'SnapshotPreviews test file could not be added to the selected XCTest target. Please check the target Sources build phase and try again.',
135+
);
136+
clack.outro('Apple Snapshots setup did not complete.');
137+
return;
138+
}
139+
140+
if (snapshotTestResult.changed || packageResult.changed) {
141+
xcProject.write();
142+
clack.log.success('Updated the Xcode project for SnapshotPreviews.');
143+
} else {
144+
clack.log.info('SnapshotPreviews Xcode project setup is already present.');
145+
}
146+
147+
await checkInstalledCLISnapshots({
148+
projectDir,
149+
verificationGuidance: {
150+
appId: xcProject.getBundleIdentifierForTarget(appTargetName),
151+
hostedTestTargetName,
152+
projectDir,
153+
projectPath: xcProject.xcodeprojPath,
154+
schemeName: resolveSnapshotVerificationSchemeName({
155+
hostedTestTargetName,
156+
xcodeprojPath: xcProject.xcodeprojPath,
157+
}),
158+
snapshotTestClassName: snapshotTestResult.className,
159+
},
160+
});
161+
162+
clack.outro(
163+
'Apple Snapshots setup complete. No Sentry auth, DSN, runtime SDK, dSYM, or CI workflow files were configured.',
164+
);
165+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type {
2+
SwiftPackageProductSpec,
3+
SwiftPackageSpec,
4+
XcodeProject,
5+
} from '../xcode-manager';
6+
import {
7+
SNAPSHOTPREVIEWS_MINIMUM_VERSION,
8+
SNAPSHOTPREVIEWS_PACKAGE_URL,
9+
SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT,
10+
SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT,
11+
} from './snapshotpreviews-package';
12+
13+
export const snapshotPreviewsPackageSpec: SwiftPackageSpec = {
14+
repositoryURL: SNAPSHOTPREVIEWS_PACKAGE_URL,
15+
requirement: {
16+
kind: 'upToNextMajorVersion',
17+
minimumVersion: SNAPSHOTPREVIEWS_MINIMUM_VERSION,
18+
},
19+
commentName: 'SnapshotPreviews',
20+
};
21+
22+
export const snapshottingTestsProductSpec: SwiftPackageProductSpec = {
23+
package: snapshotPreviewsPackageSpec,
24+
productName: SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT,
25+
};
26+
27+
export const snapshotPreferencesProductSpec: SwiftPackageProductSpec = {
28+
package: snapshotPreviewsPackageSpec,
29+
productName: SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT,
30+
};
31+
32+
export function configureSnapshotPreviewsXcodeProject({
33+
xcodeProject,
34+
hostedTestTargetName,
35+
previewTargetNames = [],
36+
}: {
37+
xcodeProject: XcodeProject;
38+
hostedTestTargetName: string;
39+
previewTargetNames?: string[];
40+
}): {
41+
changed: boolean;
42+
failedSnapshotPreferencesTargetNames: string[];
43+
linked: boolean;
44+
} {
45+
if (!xcodeProject.getUnitTestTargetNames().includes(hostedTestTargetName)) {
46+
return {
47+
changed: false,
48+
failedSnapshotPreferencesTargetNames: [],
49+
linked: false,
50+
};
51+
}
52+
53+
const snapshottingTestsResult = xcodeProject.ensureSwiftPackageProductLinked(
54+
hostedTestTargetName,
55+
snapshottingTestsProductSpec,
56+
);
57+
const snapshotPreferencesResults = previewTargetNames.map((targetName) => ({
58+
result: xcodeProject.ensureSwiftPackageProductLinked(
59+
targetName,
60+
snapshotPreferencesProductSpec,
61+
),
62+
targetName,
63+
}));
64+
const linkResults = [
65+
snapshottingTestsResult,
66+
...snapshotPreferencesResults.map(({ result }) => result),
67+
];
68+
const failedSnapshotPreferencesTargetNames = snapshotPreferencesResults
69+
.filter(({ result }) => !result.linked)
70+
.map(({ targetName }) => targetName);
71+
72+
return {
73+
changed: linkResults.some((result) => result.changed),
74+
failedSnapshotPreferencesTargetNames,
75+
linked: snapshottingTestsResult.linked,
76+
};
77+
}

0 commit comments

Comments
 (0)