diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6998bb..8276c7c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features +- feat(apple): Add Apple Snapshots wizard for SnapshotPreviews Xcode setup - feat(react-router): Use the stabilized instrumentation API (`createSentryServerInstrumentation` + `reactRouterTracingIntegration().clientInstrumentation`) instead of the experimental `useInstrumentationAPI` flag - feat(react-router): Use `sentryOnError` on `HydratedRouter` instead of mutating `root.tsx` ErrorBoundary diff --git a/bin.ts b/bin.ts index 6692c1e4..fd40ee1c 100644 --- a/bin.ts +++ b/bin.ts @@ -63,7 +63,7 @@ const PRESELECTED_PROJECT_OPTIONS: Record = { const xcodeProjectDirOption: yargs.Options = { default: undefined, describe: - 'Path to the project containing the Xcode project file. Only applies to the Apple wizard.', + 'Path to the project containing the Xcode project file. Applies to the Apple and Apple Snapshots wizards.', type: 'string', // This is a hidden option because it is used as an internal option hidden: true, diff --git a/e2e-tests/tests/help-message.test.ts b/e2e-tests/tests/help-message.test.ts index 9fc84bff..2d219488 100644 --- a/e2e-tests/tests/help-message.test.ts +++ b/e2e-tests/tests/help-message.test.ts @@ -29,9 +29,9 @@ describe('--help command', () => { env: SENTRY_WIZARD_QUIET [boolean] [default: false] -i, --integration Choose the integration to setup env: SENTRY_WIZARD_INTEGRATION - [choices: "reactNative", "flutter", "ios", "android", "cordova", "angular", - "cloudflare", "electron", "nextjs", "nuxt", "remix", "reactRouter", - "sveltekit", "sourcemaps"] + [choices: "reactNative", "flutter", "ios", "appleSnapshots", "android", + "cordova", "angular", "cloudflare", "electron", "nextjs", "nuxt", "remix", + "reactRouter", "sveltekit", "sourcemaps"] -p, --platform Choose platform(s) env: SENTRY_WIZARD_PLATFORM [array] [choices: "ios", "android"] diff --git a/lib/Constants.ts b/lib/Constants.ts index 92cab6c4..1a43e8a1 100644 --- a/lib/Constants.ts +++ b/lib/Constants.ts @@ -3,6 +3,7 @@ export enum Integration { reactNative = 'reactNative', flutter = 'flutter', ios = 'ios', + appleSnapshots = 'appleSnapshots', android = 'android', cordova = 'cordova', angular = 'angular', @@ -67,6 +68,8 @@ export function getIntegrationDescription(type: string): string { return 'Configure Source Maps Upload'; case Integration.ios: return 'iOS'; + case Integration.appleSnapshots: + return 'Apple Snapshots'; case Integration.cloudflare: return 'Cloudflare'; default: @@ -102,6 +105,8 @@ export function mapIntegrationToPlatform(type: string): string | undefined { return 'node-cloudflare-workers'; case Integration.ios: return 'iOS'; + case Integration.appleSnapshots: + return 'iOS'; default: throw new Error(`Unknown integration ${type}`); } diff --git a/src/apple/snapshots/apple-snapshots-wizard.ts b/src/apple/snapshots/apple-snapshots-wizard.ts new file mode 100644 index 00000000..946dd8ba --- /dev/null +++ b/src/apple/snapshots/apple-snapshots-wizard.ts @@ -0,0 +1,165 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; + +import { withTelemetry } from '../../telemetry'; +import { + confirmContinueIfNoOrDirtyGitRepo, + printWelcome, +} from '../../utils/clack'; +import { lookupXcodeProject, selectXcodeTarget } from '../lookup-xcode-project'; +import type { AppleWizardOptions } from '../options'; +import { checkInstalledCLISnapshots } from './snapshots-cli-preflight'; +import { configureSnapshotPreviewsXcodeProject } from './configure-snapshotpreviews-xcode-project'; +import { ensureSnapshotTestFile } from './snapshot-test-file'; +import { resolveSnapshotVerificationSchemeName } from './snapshot-verification-scheme'; +import { + SNAPSHOTPREVIEWS_MINIMUM_VERSION, + SNAPSHOTPREVIEWS_PACKAGE_URL, + SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT, + SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT, +} from './snapshotpreviews-package'; + +export async function runAppleSnapshotsWizard( + options: AppleWizardOptions, +): Promise { + return withTelemetry( + { + enabled: options.telemetryEnabled, + integration: 'appleSnapshots', + wizardOptions: options, + }, + () => runAppleSnapshotsWizardWithTelemetry(options), + ); +} + +async function runAppleSnapshotsWizardWithTelemetry( + options: AppleWizardOptions, +): Promise { + const projectDir = options.projectDir ?? process.cwd(); + + printWelcome({ + wizardName: 'Sentry Apple Snapshots Wizard', + promoCode: options.promoCode, + }); + + await confirmContinueIfNoOrDirtyGitRepo({ + ignoreGitChanges: options.ignoreGitChanges, + cwd: projectDir, + }); + + clack.log.info( + [ + `Apple Snapshots setup will use ${SNAPSHOTPREVIEWS_PACKAGE_URL}`, + `${SNAPSHOTPREVIEWS_MINIMUM_VERSION}+ and link`, + `${SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT} to the hosted XCTest`, + `target and ${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} to the selected`, + 'app target.', + ].join(' '), + ); + + const xcProject = await lookupXcodeProject({ projectDir }); + + const appTargetName = await selectXcodeTarget(xcProject, { + noTargetMessage: 'No application target found.', + promptMessage: 'Which app target should SnapshotPreviews use?', + }); + + const hostedTestTargetNames = + xcProject.getHostedUnitTestTargetNamesForApplicationTarget(appTargetName); + const hostedTestTargetName = await selectXcodeTarget(xcProject, { + targetNames: hostedTestTargetNames, + noTargetMessage: [ + `No hosted unit-test target was found for ${appTargetName}.`, + 'Please configure a unit-test target with TEST_HOST pointing at that app target, then run the wizard again.', + ].join(' '), + promptMessage: 'Which test target should render SnapshotPreviews?', + }); + + const previewTargetNames = [appTargetName]; + + clack.log.info( + [ + `${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} will be linked to the selected`, + 'app target when possible. Import SnapshotPreferences in Swift', + 'preview files only if you want to use SnapshotPreviews modifiers.', + ].join(' '), + ); + + if (fs.existsSync(path.join(projectDir, 'Package.swift'))) { + clack.log.info( + [ + 'Detected Package.swift. This wizard does not edit SwiftPM manifests.', + `If SwiftPM preview targets use SnapshotPreferences modifiers, add`, + `the ${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} product dependency to those`, + 'targets in Package.swift.', + ].join(' '), + ); + } + + const packageResult = configureSnapshotPreviewsXcodeProject({ + xcodeProject: xcProject, + hostedTestTargetName, + previewTargetNames, + }); + + if (!packageResult.linked) { + clack.log.error( + 'SnapshotPreviews package products could not be linked to the selected targets. Please check the Xcode project target build phases and try again.', + ); + clack.outro('Apple Snapshots setup did not complete.'); + return; + } + + if (packageResult.failedSnapshotPreferencesTargetNames.length) { + clack.log.warn( + [ + `${SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT} could not be linked to`, + packageResult.failedSnapshotPreferencesTargetNames.join(', '), + 'because the target Frameworks build phase could not be updated.', + 'Apple Snapshots setup will continue; link the product manually if', + 'you want to use SnapshotPreviews modifiers in Swift previews.', + ].join(' '), + ); + } + + const snapshotTestResult = ensureSnapshotTestFile({ + xcodeProject: xcProject, + hostedTestTargetName, + }); + if (!snapshotTestResult.included) { + clack.log.error( + 'SnapshotPreviews test file could not be added to the selected XCTest target. Please check the target Sources build phase and try again.', + ); + clack.outro('Apple Snapshots setup did not complete.'); + return; + } + + if (snapshotTestResult.changed || packageResult.changed) { + xcProject.write(); + clack.log.success('Updated the Xcode project for SnapshotPreviews.'); + } else { + clack.log.info('SnapshotPreviews Xcode project setup is already present.'); + } + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance: { + appId: xcProject.getBundleIdentifierForTarget(appTargetName), + hostedTestTargetName, + projectDir, + projectPath: xcProject.xcodeprojPath, + schemeName: resolveSnapshotVerificationSchemeName({ + hostedTestTargetName, + xcodeprojPath: xcProject.xcodeprojPath, + }), + snapshotTestClassName: snapshotTestResult.className, + }, + }); + + clack.outro( + 'Apple Snapshots setup complete. No Sentry auth, DSN, runtime SDK, dSYM, or CI workflow files were configured.', + ); +} diff --git a/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts b/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts new file mode 100644 index 00000000..f74f4a55 --- /dev/null +++ b/src/apple/snapshots/configure-snapshotpreviews-xcode-project.ts @@ -0,0 +1,77 @@ +import type { + SwiftPackageProductSpec, + SwiftPackageSpec, + XcodeProject, +} from '../xcode-manager'; +import { + SNAPSHOTPREVIEWS_MINIMUM_VERSION, + SNAPSHOTPREVIEWS_PACKAGE_URL, + SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT, + SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT, +} from './snapshotpreviews-package'; + +export const snapshotPreviewsPackageSpec: SwiftPackageSpec = { + repositoryURL: SNAPSHOTPREVIEWS_PACKAGE_URL, + requirement: { + kind: 'upToNextMajorVersion', + minimumVersion: SNAPSHOTPREVIEWS_MINIMUM_VERSION, + }, + commentName: 'SnapshotPreviews', +}; + +export const snapshottingTestsProductSpec: SwiftPackageProductSpec = { + package: snapshotPreviewsPackageSpec, + productName: SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT, +}; + +export const snapshotPreferencesProductSpec: SwiftPackageProductSpec = { + package: snapshotPreviewsPackageSpec, + productName: SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT, +}; + +export function configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName, + previewTargetNames = [], +}: { + xcodeProject: XcodeProject; + hostedTestTargetName: string; + previewTargetNames?: string[]; +}): { + changed: boolean; + failedSnapshotPreferencesTargetNames: string[]; + linked: boolean; +} { + if (!xcodeProject.getUnitTestTargetNames().includes(hostedTestTargetName)) { + return { + changed: false, + failedSnapshotPreferencesTargetNames: [], + linked: false, + }; + } + + const snapshottingTestsResult = xcodeProject.ensureSwiftPackageProductLinked( + hostedTestTargetName, + snapshottingTestsProductSpec, + ); + const snapshotPreferencesResults = previewTargetNames.map((targetName) => ({ + result: xcodeProject.ensureSwiftPackageProductLinked( + targetName, + snapshotPreferencesProductSpec, + ), + targetName, + })); + const linkResults = [ + snapshottingTestsResult, + ...snapshotPreferencesResults.map(({ result }) => result), + ]; + const failedSnapshotPreferencesTargetNames = snapshotPreferencesResults + .filter(({ result }) => !result.linked) + .map(({ targetName }) => targetName); + + return { + changed: linkResults.some((result) => result.changed), + failedSnapshotPreferencesTargetNames, + linked: snapshottingTestsResult.linked, + }; +} diff --git a/src/apple/snapshots/snapshot-test-file.ts b/src/apple/snapshots/snapshot-test-file.ts new file mode 100644 index 00000000..3a374c16 --- /dev/null +++ b/src/apple/snapshots/snapshot-test-file.ts @@ -0,0 +1,146 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { XcodeProject } from '../xcode-manager'; + +const SNAPSHOT_TEST_IMPORT = 'import SnapshottingTests'; + +type EnsureSnapshotTestFileResult = + | { changed: false; included: false } + | { + changed: boolean; + className: string; + filePath: string; + included: true; + }; + +export function ensureSnapshotTestFile({ + xcodeProject, + hostedTestTargetName, +}: { + xcodeProject: XcodeProject; + hostedTestTargetName: string; +}): EnsureSnapshotTestFileResult { + const existingSnapshotTestFile = findExistingSnapshotTestFile( + xcodeProject, + hostedTestTargetName, + ); + if (existingSnapshotTestFile) { + return { + changed: false, + className: existingSnapshotTestFile.className, + filePath: existingSnapshotTestFile.filePath, + included: true, + }; + } + + const className = snapshotTestClassName(hostedTestTargetName); + const filePath = path.join( + getSnapshotTestDirectory(xcodeProject, hostedTestTargetName), + `${className}.swift`, + ); + + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const fileCreated = !fs.existsSync(filePath); + if (fileCreated) { + fs.writeFileSync(filePath, snapshotTestTemplate(className), 'utf8'); + } + + const membership = xcodeProject.addSwiftSourceFileToTarget({ + targetName: hostedTestTargetName, + filePath, + }); + + if (!membership.included) { + if (fileCreated) { + fs.unlinkSync(filePath); + } + + return { changed: false, included: false }; + } + + return { + changed: fileCreated || membership.changed, + className, + filePath, + included: true, + }; +} + +export function snapshotTestClassName(hostedTestTargetName: string): string { + return `${swiftSafeTypeName(hostedTestTargetName)}SnapshotTest`; +} + +export function snapshotTestTemplate(className: string): string { + return `${SNAPSHOT_TEST_IMPORT} + +final class ${className}: SnapshotTest { + override class func snapshotPreviews() -> [String]? { nil } + override class func excludedSnapshotPreviews() -> [String]? { nil } + override class func snapshotPreviewModules() -> [String]? { nil } + override class func excludedSnapshotPreviewModules() -> [String]? { nil } +} +`; +} + +export function swiftSafeTypeName(value: string): string { + const words = value.split(/[^A-Za-z0-9]+/).filter((word) => word.length > 0); + const typeName = words + .map((word) => `${word[0].toUpperCase()}${word.slice(1)}`) + .join(''); + + if (!typeName) { + return 'Snapshots'; + } + + return /^[0-9]/.test(typeName) ? `_${typeName}` : typeName; +} + +function findExistingSnapshotTestFile( + xcodeProject: XcodeProject, + hostedTestTargetName: string, +): { className: string; filePath: string } | undefined { + const sourceFiles = + xcodeProject.getSourceFilesForTarget(hostedTestTargetName) ?? []; + for (const filePath of sourceFiles) { + if (!filePath.endsWith('.swift') || !fs.existsSync(filePath)) { + continue; + } + + const contents = fs.readFileSync(filePath, 'utf8'); + const className = getSnapshotTestClassName(contents); + if (contents.includes(SNAPSHOT_TEST_IMPORT) && className) { + return { className, filePath }; + } + } + + return undefined; +} + +function getSnapshotTestClassName(contents: string): string | undefined { + return contents.match( + /\bclass\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*SnapshotTest\b/, + )?.[1]; +} + +function getSnapshotTestDirectory( + xcodeProject: XcodeProject, + hostedTestTargetName: string, +): string { + const synchronizedRootGroupPath = + xcodeProject.getSynchronizedRootGroupPathsForTarget( + hostedTestTargetName, + )[0]; + if (synchronizedRootGroupPath) { + return synchronizedRootGroupPath; + } + + const existingSwiftFile = xcodeProject + .getSourceFilesForTarget(hostedTestTargetName) + ?.find((filePath) => filePath.endsWith('.swift')); + if (existingSwiftFile) { + return path.dirname(existingSwiftFile); + } + + return path.join(xcodeProject.baseDir, hostedTestTargetName); +} diff --git a/src/apple/snapshots/snapshot-verification-scheme.ts b/src/apple/snapshots/snapshot-verification-scheme.ts new file mode 100644 index 00000000..79633dd2 --- /dev/null +++ b/src/apple/snapshots/snapshot-verification-scheme.ts @@ -0,0 +1,166 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import xml from 'xml-js'; + +import { debug } from '../../utils/debug'; +import { findFilesWithExtension, findFilesWithName } from '../../utils/files'; + +type SnapshotVerificationSchemeOptions = { + hostedTestTargetName: string; + xcodeprojPath: string; +}; + +type ExplicitScheme = { + name: string; + testTargetNames: string[]; +}; + +export function resolveSnapshotVerificationSchemeName({ + hostedTestTargetName, + xcodeprojPath, +}: SnapshotVerificationSchemeOptions): string | undefined { + const explicitSchemes = getExplicitSchemes(xcodeprojPath); + const matchingExplicitSchemes = explicitSchemes.filter((scheme) => + scheme.testTargetNames.includes(hostedTestTargetName), + ); + const matchingExplicitSchemeNames = uniqueStrings( + matchingExplicitSchemes.map((scheme) => scheme.name), + ); + if (matchingExplicitSchemeNames.length === 1) { + return matchingExplicitSchemeNames[0]; + } + + const managedSchemeNames = getManagedSchemeNames(xcodeprojPath); + if (managedSchemeNames.length === 1) { + return managedSchemeNames[0]; + } + + return undefined; +} + +function getExplicitSchemes(xcodeprojPath: string): ExplicitScheme[] { + return getSchemeFilePaths(xcodeprojPath).flatMap((schemePath) => { + const schemeName = path.basename(schemePath, '.xcscheme'); + const testTargetNames = readSchemeTestTargetNames(schemePath); + return schemeName ? [{ name: schemeName, testTargetNames }] : []; + }); +} + +function getSchemeFilePaths(xcodeprojPath: string): string[] { + return [ + path.join(xcodeprojPath, 'xcshareddata', 'xcschemes'), + path.join(xcodeprojPath, 'xcuserdata'), + ].flatMap((schemeDirectory) => + findFilesWithExtension(schemeDirectory, '.xcscheme'), + ); +} + +function readSchemeTestTargetNames(schemePath: string): string[] { + const scheme = readXmlFile(schemePath); + if (!scheme) { + return []; + } + + const testAction = getChild(getChild(scheme, 'Scheme'), 'TestAction'); + const targetNames = new Set(); + collectBlueprintNames(testAction, targetNames); + return [...targetNames]; +} + +function getManagedSchemeNames(xcodeprojPath: string): string[] { + return uniqueStrings( + findFilesWithName(xcodeprojPath, 'xcschememanagement.plist').flatMap( + readManagedSchemeNames, + ), + ); +} + +function readManagedSchemeNames(plistPath: string): string[] { + const plist = readXmlFile(plistPath); + if (!plist) { + return []; + } + + const schemeNames: string[] = []; + collectElementText(plist, 'key').forEach((key) => { + const schemeName = parseManagedSchemeName(key); + if (schemeName) { + schemeNames.push(schemeName); + } + }); + return schemeNames; +} + +function parseManagedSchemeName(key: string): string | undefined { + return /^(.+)\.xcscheme(?:_.+)?$/.exec(key)?.[1]; +} + +function readXmlFile(filePath: string): unknown | undefined { + try { + return xml.xml2js(fs.readFileSync(filePath, 'utf8'), { + compact: true, + }); + } catch (error) { + debug('Could not read XML file:', filePath, error); + return undefined; + } +} + +function getChild(node: unknown, childName: string): unknown { + return isRecord(node) ? node[childName] : undefined; +} + +function collectBlueprintNames(node: unknown, targetNames: Set): void { + if (Array.isArray(node)) { + node.forEach((item) => collectBlueprintNames(item, targetNames)); + return; + } + + if (!isRecord(node)) { + return; + } + + const attributes = node._attributes; + if (isRecord(attributes) && typeof attributes.BlueprintName === 'string') { + targetNames.add(attributes.BlueprintName); + } + + Object.values(node).forEach((value) => + collectBlueprintNames(value, targetNames), + ); +} + +function collectElementText(node: unknown, elementName: string): string[] { + if (Array.isArray(node)) { + return node.flatMap((item) => collectElementText(item, elementName)); + } + + if (!isRecord(node)) { + return []; + } + + return Object.entries(node).flatMap(([key, value]) => { + const childText = key === elementName ? collectTextValues(value) : []; + return childText.concat(collectElementText(value, elementName)); + }); +} + +function collectTextValues(node: unknown): string[] { + if (Array.isArray(node)) { + return node.flatMap(collectTextValues); + } + + if (!isRecord(node)) { + return []; + } + + return typeof node._text === 'string' ? [node._text] : []; +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values)]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/apple/snapshots/snapshotpreviews-package.ts b/src/apple/snapshots/snapshotpreviews-package.ts new file mode 100644 index 00000000..42eed5c2 --- /dev/null +++ b/src/apple/snapshots/snapshotpreviews-package.ts @@ -0,0 +1,8 @@ +export const SNAPSHOTPREVIEWS_PACKAGE_URL = + 'https://github.com/getsentry/SnapshotPreviews'; + +export const SNAPSHOTPREVIEWS_MINIMUM_VERSION = '0.17.0'; + +export const SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT = 'SnapshottingTests'; + +export const SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT = 'SnapshotPreferences'; diff --git a/src/apple/snapshots/snapshots-cli-preflight.ts b/src/apple/snapshots/snapshots-cli-preflight.ts new file mode 100644 index 00000000..21f3cc7d --- /dev/null +++ b/src/apple/snapshots/snapshots-cli-preflight.ts @@ -0,0 +1,156 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +// @ts-expect-error - clack is ESM and TS complains about that. It works though +import clack from '@clack/prompts'; +import * as Sentry from '@sentry/node'; +import * as bash from '../../utils/bash'; +import { checkInstalledCLI } from '../check-installed-cli'; +import * as fastlane from '../fastlane'; + +type SnapshotVerificationGuidance = { + appId?: string; + hostedTestTargetName: string; + projectDir: string; + projectPath: string; + schemeName?: string; + snapshotTestClassName: string; +}; + +export async function checkInstalledCLISnapshots({ + projectDir, + verificationGuidance, +}: { + projectDir: string; + verificationGuidance: SnapshotVerificationGuidance; +}): Promise { + const hasCli = await checkInstalledCLI( + "Without sentry-cli, you won't be able to upload snapshots to Sentry. You can install it later by following the instructions at https://docs.sentry.io/cli/", + ); + + if (hasCli) { + verifySnapshotsUploadCommand(); + } + + printSnapshotsUploadGuidance({ + fastlaneGuidance: getSnapshotFastlaneGuidance(projectDir), + verificationGuidance, + }); +} + +export function getSnapshotFastlaneGuidance(projectDir: string) { + const fastfilePath = fastlane.fastFile(projectDir) ?? undefined; + if (!fastfilePath) { + return { + hasUploadLane: false, + hasSentryUploadAction: false, + }; + } + + let contents: string; + try { + contents = fs.readFileSync(fastfilePath, 'utf8'); + } catch { + clack.log.warn( + `Could not read the Fastfile at ${fastfilePath}. Skipping Fastlane snapshots upload detection.`, + ); + return { + hasUploadLane: false, + hasSentryUploadAction: false, + }; + } + + return { + fastfilePath, + hasUploadLane: /lane\s+:upload_sentry_snapshots\b/.test(contents), + hasSentryUploadAction: /\bsentry_upload_snapshots\s*\(/.test(contents), + }; +} + +function verifySnapshotsUploadCommand(): void { + try { + bash.executeSync('sentry-cli snapshots upload --help'); + Sentry.setTag('sentry-cli-snapshots-upload', true); + clack.log.success('sentry-cli snapshots upload is available.'); + } catch { + Sentry.setTag('sentry-cli-snapshots-upload', false); + clack.log.warn( + 'sentry-cli is installed, but this version does not expose `sentry-cli snapshots upload`. Please update sentry-cli before uploading snapshots.', + ); + } +} + +function printSnapshotsUploadGuidance({ + fastlaneGuidance, + verificationGuidance, +}: { + fastlaneGuidance: ReturnType; + verificationGuidance: SnapshotVerificationGuidance; +}): void { + if (fastlaneGuidance.fastfilePath && fastlaneGuidance.hasUploadLane) { + 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"', + ].join('\n'), + ); + return; + } + + if (fastlaneGuidance.fastfilePath && fastlaneGuidance.hasSentryUploadAction) { + clack.log.info( + [ + 'Detected a Fastlane lane that calls sentry_upload_snapshots.', + 'After exporting snapshots, run that existing lane with your snapshot image path.', + 'If you are unsure which lane to use, fall back to sentry-cli snapshots upload below.', + ].join('\n'), + ); + } + + clack.log.info( + [ + buildSnapshotExportCommand(verificationGuidance), + '', + 'After SnapshotPreviews exports images, verify local upload with:', + 'sentry-cli snapshots upload "$PWD/snapshot-images" \\', + ' --org your-org \\', + ' --project your-ios-project \\', + verificationGuidance.appId + ? ` --app-id ${verificationGuidance.appId}` + : ' --app-id YOUR_APP_BUNDLE_ID # replace with your app bundle identifier', + '', + 'Auth comes from SENTRY_AUTH_TOKEN or --auth-token. CI workflow setup is documented at https://docs.sentry.io/platforms/apple/snapshots/#step-3-integrate-into-ci; this wizard does not write workflow files.', + ].join('\n'), + ); +} + +function buildSnapshotExportCommand( + guidance: SnapshotVerificationGuidance, +): string { + 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, + )} \\`, + ` -scheme ${ + guidance.schemeName ? quoteShell(guidance.schemeName) : '' + } \\`, + " -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \\", + ` -only-testing:${quoteShell( + `${guidance.hostedTestTargetName}/${guidance.snapshotTestClassName}`, + )} \\`, + ' CODE_SIGNING_ALLOWED=NO \\', + ' | xcpretty', + ].join('\n'); +} + +function quoteShell(value: string): string { + return /^[A-Za-z0-9_./:-]+$/.test(value) + ? value + : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/src/run.ts b/src/run.ts index c3304b08..575e923b 100644 --- a/src/run.ts +++ b/src/run.ts @@ -9,6 +9,7 @@ import { run as legacyRun } from '../lib/Setup'; import { runAndroidWizard } from './android/android-wizard'; import { runAngularWizard } from './angular/angular-wizard'; import { runAppleWizard } from './apple/apple-wizard'; +import { runAppleSnapshotsWizard } from './apple/snapshots/apple-snapshots-wizard'; import { runFlutterWizard } from './flutter/flutter-wizard'; import { runNextjsWizard } from './nextjs/nextjs-wizard'; import { runNuxtWizard } from './nuxt/nuxt-wizard'; @@ -26,6 +27,7 @@ type WizardIntegration = | 'reactNative' | 'flutter' | 'ios' + | 'appleSnapshots' | 'android' | 'cordova' | 'electron' @@ -121,6 +123,7 @@ export async function run(argv: Args) { { value: 'reactNative', label: 'React Native' }, { value: 'flutter', label: 'Flutter' }, { value: 'ios', label: 'iOS' }, + { value: 'appleSnapshots', label: 'Apple Snapshots' }, { value: 'angular', label: 'Angular' }, { value: 'android', label: 'Android' }, { value: 'cordova', label: 'Cordova' }, @@ -174,6 +177,13 @@ export async function run(argv: Args) { }); break; + case 'appleSnapshots': + await runAppleSnapshotsWizard({ + ...wizardOptions, + projectDir: finalArgs.xcodeProjectDir, + }); + break; + case 'android': await runAndroidWizard(wizardOptions); break; diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 00000000..eda802e5 --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,47 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { debug } from './debug'; + +export function findFilesWithExtension( + directory: string, + extension: string, +): string[] { + return findFiles(directory, (filePath) => filePath.endsWith(extension)); +} + +export function findFilesWithName( + directory: string, + fileName: string, +): string[] { + return findFiles( + directory, + (filePath) => path.basename(filePath) === fileName, + ); +} + +function findFiles( + directory: string, + predicate: (filePath: string) => boolean, +): string[] { + if (!fs.existsSync(directory)) { + return []; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(directory, { withFileTypes: true }); + } catch (error) { + debug('Could not read directory while finding files:', directory, error); + return []; + } + + return entries.flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return findFiles(entryPath, predicate); + } + + return predicate(entryPath) ? [entryPath] : []; + }); +} diff --git a/test/apple/snapshots/apple-snapshots-wizard.test.ts b/test/apple/snapshots/apple-snapshots-wizard.test.ts new file mode 100644 index 00000000..0a900dbb --- /dev/null +++ b/test/apple/snapshots/apple-snapshots-wizard.test.ts @@ -0,0 +1,350 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + askForItemSelection: vi.fn(), + checkInstalledCLISnapshots: vi.fn(), + confirm: vi.fn(), + confirmContinueIfNoOrDirtyGitRepo: vi.fn(), + configureSnapshotPreviewsXcodeProject: vi.fn(), + ensureSnapshotTestFile: vi.fn(), + info: vi.fn(), + lookupXcodeProject: vi.fn(), + outro: vi.fn(), + printWelcome: vi.fn(), + resolveSnapshotVerificationSchemeName: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + withTelemetry: vi.fn( + async (_options: { integration: string }, callback: () => Promise) => + await callback(), + ), + write: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + default: { + confirm: mocks.confirm, + log: { + info: mocks.info, + success: mocks.success, + warn: mocks.warn, + }, + outro: mocks.outro, + }, +})); + +vi.mock('../../../src/telemetry', () => ({ + withTelemetry: mocks.withTelemetry, +})); + +vi.mock('../../../src/utils/clack', () => ({ + abortIfCancelled: async (value: T | Promise) => await value, + askForItemSelection: mocks.askForItemSelection, + confirmContinueIfNoOrDirtyGitRepo: mocks.confirmContinueIfNoOrDirtyGitRepo, + printWelcome: mocks.printWelcome, +})); + +vi.mock('../../../src/apple/lookup-xcode-project', () => ({ + lookupXcodeProject: mocks.lookupXcodeProject, + selectXcodeTarget: async ( + xcodeProject: { getAllTargets: () => string[] }, + options: { + targetNames?: string[]; + promptMessage?: string; + } = {}, + ): Promise => { + const targetNames = options.targetNames ?? xcodeProject.getAllTargets(); + if (targetNames.length === 1) { + return targetNames[0]; + } + + const askForItemSelection = mocks.askForItemSelection as ( + items: string[], + message: string, + ) => Promise<{ value: string }>; + const selection = await askForItemSelection( + targetNames, + options.promptMessage ?? 'Which target do you want to add Sentry to?', + ); + return selection.value; + }, +})); + +vi.mock('../../../src/apple/snapshots/snapshot-test-file', () => ({ + ensureSnapshotTestFile: mocks.ensureSnapshotTestFile, +})); + +vi.mock( + '../../../src/apple/snapshots/configure-snapshotpreviews-xcode-project', + () => ({ + configureSnapshotPreviewsXcodeProject: + mocks.configureSnapshotPreviewsXcodeProject, + }), +); + +vi.mock('../../../src/apple/snapshots/snapshots-cli-preflight', () => ({ + checkInstalledCLISnapshots: mocks.checkInstalledCLISnapshots, +})); + +vi.mock('../../../src/apple/snapshots/snapshot-verification-scheme', () => ({ + resolveSnapshotVerificationSchemeName: + mocks.resolveSnapshotVerificationSchemeName, +})); + +import { runAppleSnapshotsWizard } from '../../../src/apple/snapshots/apple-snapshots-wizard'; + +describe('runAppleSnapshotsWizard', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureSnapshotTestFile.mockReturnValue({ + changed: true, + className: 'AppTestsSnapshotTest', + filePath: '/tmp/AppTestsSnapshotTest.swift', + included: true, + }); + mocks.configureSnapshotPreviewsXcodeProject.mockReturnValue({ + changed: true, + failedSnapshotPreferencesTargetNames: [], + linked: true, + }); + mocks.resolveSnapshotVerificationSchemeName.mockReturnValue('AppScheme'); + mocks.askForItemSelection.mockResolvedValue({ value: 'App' }); + mocks.confirm.mockResolvedValue(false); + }); + + it('links SnapshotPreferences only to the selected app target', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshots-wizard-')); + const selectedPreviewFile = path.join(tempDir, 'SelectedView.swift'); + const otherPreviewFile = path.join(tempDir, 'OtherView.swift'); + fs.writeFileSync( + selectedPreviewFile, + '#Preview { SelectedView() }', + 'utf8', + ); + fs.writeFileSync(otherPreviewFile, '#Preview { OtherView() }', 'utf8'); + mocks.askForItemSelection.mockResolvedValue({ value: 'App' }); + const xcodeProject = { + getBundleIdentifierForTarget: vi.fn(() => 'com.getsentry.App'), + getSourceFilesForTarget: vi.fn((targetName: string) => + targetName === 'App' ? [selectedPreviewFile] : [otherPreviewFile], + ), + getAllTargets: vi.fn(() => ['App', 'OtherApp']), + getHostedUnitTestTargetNamesForApplicationTarget: vi.fn(() => [ + 'AppTests', + ]), + getUnitTestTargetNames: vi.fn(() => ['AppTests']), + write: mocks.write, + xcodeprojPath: path.join(tempDir, 'App.xcodeproj'), + }; + mocks.lookupXcodeProject.mockResolvedValue(xcodeProject); + + await runAppleSnapshotsWizard({ + telemetryEnabled: true, + projectDir: tempDir, + promoCode: 'CAM', + ignoreGitChanges: true, + }); + + expect(mocks.askForItemSelection).toHaveBeenCalledWith( + ['App', 'OtherApp'], + 'Which app target should SnapshotPreviews use?', + ); + expect(mocks.configureSnapshotPreviewsXcodeProject).toHaveBeenCalledWith({ + xcodeProject, + hostedTestTargetName: 'AppTests', + previewTargetNames: ['App'], + }); + }); + + it('prompts only among unit-test targets hosted by the selected app target', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshots-wizard-')); + const previewFile = path.join(tempDir, 'ContentView.swift'); + fs.writeFileSync(previewFile, '#Preview { ContentView() }', 'utf8'); + mocks.askForItemSelection.mockResolvedValue({ + value: 'AppSnapshotTests', + }); + const xcodeProject = { + getBundleIdentifierForTarget: vi.fn(() => 'com.getsentry.App'), + getSourceFilesForTarget: vi.fn(() => [previewFile]), + getAllTargets: vi.fn(() => ['App']), + getHostedUnitTestTargetNamesForApplicationTarget: vi.fn(() => [ + 'AppTests', + 'AppSnapshotTests', + ]), + write: mocks.write, + xcodeprojPath: path.join(tempDir, 'App.xcodeproj'), + }; + mocks.lookupXcodeProject.mockResolvedValue(xcodeProject); + + await runAppleSnapshotsWizard({ + telemetryEnabled: true, + projectDir: tempDir, + promoCode: 'CAM', + ignoreGitChanges: true, + }); + + expect( + xcodeProject.getHostedUnitTestTargetNamesForApplicationTarget, + ).toHaveBeenCalledWith('App'); + expect(mocks.askForItemSelection).toHaveBeenCalledWith( + ['AppTests', 'AppSnapshotTests'], + 'Which test target should render SnapshotPreviews?', + ); + expect(mocks.configureSnapshotPreviewsXcodeProject).toHaveBeenCalledWith({ + xcodeProject, + hostedTestTargetName: 'AppSnapshotTests', + previewTargetNames: ['App'], + }); + }); + + it('links SnapshotPreferences to the selected app target without scanning for Swift previews', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshots-wizard-')); + const selectedFile = path.join(tempDir, 'SelectedView.swift'); + const otherPreviewFile = path.join(tempDir, 'OtherView.swift'); + fs.writeFileSync(selectedFile, 'struct SelectedView {}', 'utf8'); + fs.writeFileSync(otherPreviewFile, '#Preview { OtherView() }', 'utf8'); + mocks.askForItemSelection.mockResolvedValue({ value: 'App' }); + const xcodeProject = { + getBundleIdentifierForTarget: vi.fn(() => 'com.getsentry.App'), + getSourceFilesForTarget: vi.fn((targetName: string) => + targetName === 'App' ? [selectedFile] : [otherPreviewFile], + ), + getAllTargets: vi.fn(() => ['App', 'OtherApp']), + getHostedUnitTestTargetNamesForApplicationTarget: vi.fn(() => [ + 'AppTests', + ]), + getUnitTestTargetNames: vi.fn(() => ['AppTests']), + write: mocks.write, + xcodeprojPath: path.join(tempDir, 'App.xcodeproj'), + }; + mocks.lookupXcodeProject.mockResolvedValue(xcodeProject); + + await runAppleSnapshotsWizard({ + telemetryEnabled: true, + projectDir: tempDir, + promoCode: 'CAM', + ignoreGitChanges: true, + }); + + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(xcodeProject.getSourceFilesForTarget).not.toHaveBeenCalled(); + expect(mocks.configureSnapshotPreviewsXcodeProject).toHaveBeenCalledWith({ + xcodeProject, + hostedTestTargetName: 'AppTests', + previewTargetNames: ['App'], + }); + }); + + it('warns when optional SnapshotPreferences linking fails', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshots-wizard-')); + const previewFile = path.join(tempDir, 'ContentView.swift'); + fs.writeFileSync(previewFile, '#Preview { ContentView() }', 'utf8'); + mocks.configureSnapshotPreviewsXcodeProject.mockReturnValue({ + changed: true, + failedSnapshotPreferencesTargetNames: ['App'], + linked: true, + }); + const xcodeProject = { + getBundleIdentifierForTarget: vi.fn(() => 'com.getsentry.App'), + getSourceFilesForTarget: vi.fn(() => [previewFile]), + getAllTargets: vi.fn(() => ['App']), + getHostedUnitTestTargetNamesForApplicationTarget: vi.fn(() => [ + 'AppTests', + ]), + getUnitTestTargetNames: vi.fn(() => ['AppTests']), + write: mocks.write, + xcodeprojPath: path.join(tempDir, 'App.xcodeproj'), + }; + mocks.lookupXcodeProject.mockResolvedValue(xcodeProject); + + await runAppleSnapshotsWizard({ + telemetryEnabled: true, + projectDir: tempDir, + promoCode: 'CAM', + ignoreGitChanges: true, + }); + + expect(mocks.warn).toHaveBeenCalledWith( + expect.stringContaining('SnapshotPreferences could not be linked to App'), + ); + expect(mocks.checkInstalledCLISnapshots).toHaveBeenCalledTimes(1); + }); + + it('wires the Apple Snapshots flow without Sentry auth/runtime/CI mutation', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshots-wizard-')); + const previewFile = path.join(tempDir, 'ContentView.swift'); + fs.writeFileSync(previewFile, '#Preview { ContentView() }', 'utf8'); + fs.writeFileSync(path.join(tempDir, 'Package.swift'), '', 'utf8'); + const xcodeProject = { + getBundleIdentifierForTarget: vi.fn(() => 'com.getsentry.App'), + getSourceFilesForTarget: vi.fn(() => [previewFile]), + getAllTargets: vi.fn(() => ['App']), + getHostedUnitTestTargetNamesForApplicationTarget: vi.fn(() => [ + 'AppTests', + ]), + getUnitTestTargetNames: vi.fn(() => ['AppTests']), + write: mocks.write, + xcodeprojPath: path.join(tempDir, 'App.xcodeproj'), + }; + mocks.lookupXcodeProject.mockResolvedValue(xcodeProject); + + await runAppleSnapshotsWizard({ + telemetryEnabled: true, + projectDir: tempDir, + promoCode: 'CAM', + ignoreGitChanges: true, + }); + + expect(mocks.withTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ integration: 'appleSnapshots' }), + expect.any(Function), + ); + expect(mocks.printWelcome).toHaveBeenCalledWith({ + wizardName: 'Sentry Apple Snapshots Wizard', + promoCode: 'CAM', + }); + expect(mocks.confirmContinueIfNoOrDirtyGitRepo).toHaveBeenCalledWith({ + ignoreGitChanges: true, + cwd: tempDir, + }); + expect(mocks.lookupXcodeProject).toHaveBeenCalledWith({ + projectDir: tempDir, + }); + expect(mocks.confirm).not.toHaveBeenCalled(); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('SnapshotPreferences in Swift preview files'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('This wizard does not edit SwiftPM manifests'), + ); + expect(mocks.ensureSnapshotTestFile).toHaveBeenCalledWith({ + xcodeProject, + hostedTestTargetName: 'AppTests', + }); + expect(mocks.configureSnapshotPreviewsXcodeProject).toHaveBeenCalledWith({ + xcodeProject, + hostedTestTargetName: 'AppTests', + previewTargetNames: ['App'], + }); + expect(mocks.write).toHaveBeenCalledTimes(1); + expect(mocks.checkInstalledCLISnapshots).toHaveBeenCalledWith({ + projectDir: tempDir, + verificationGuidance: { + appId: 'com.getsentry.App', + hostedTestTargetName: 'AppTests', + projectDir: tempDir, + projectPath: path.join(tempDir, 'App.xcodeproj'), + schemeName: 'AppScheme', + snapshotTestClassName: 'AppTestsSnapshotTest', + }, + }); + expect(mocks.outro).toHaveBeenCalledWith( + expect.stringContaining( + 'No Sentry auth, DSN, runtime SDK, dSYM, or CI workflow files', + ), + ); + }); +}); diff --git a/test/apple/snapshots/snapshot-test-file.test.ts b/test/apple/snapshots/snapshot-test-file.test.ts new file mode 100644 index 00000000..5269b605 --- /dev/null +++ b/test/apple/snapshots/snapshot-test-file.test.ts @@ -0,0 +1,119 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ensureSnapshotTestFile, + swiftSafeTypeName, +} from '../../../src/apple/snapshots/snapshot-test-file'; +import { XcodeProject } from '../../../src/apple/xcode-manager'; +import { copySingleTargetProjectToTemp } from './hosted-test-target-fixture'; + +vi.mock('@clack/prompts', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + step: vi.fn(), + }, +})); + +describe('ensureSnapshotTestFile', () => { + it('creates a minimal SnapshotTest file and reruns without duplication', () => { + const xcodeProject = new XcodeProject( + copySingleTargetProjectToTemp('snapshot-test-file-'), + ); + + const firstResult = ensureSnapshotTestFile({ + xcodeProject, + hostedTestTargetName: 'Project', + }); + const secondResult = ensureSnapshotTestFile({ + xcodeProject, + hostedTestTargetName: 'Project', + }); + + expect(firstResult.changed).toBe(true); + expect(firstResult.included).toBe(true); + expect(secondResult.changed).toBe(false); + expect(secondResult.included).toBe(true); + if (!firstResult.included || !secondResult.included) { + throw new Error('Expected snapshot test files to be included'); + } + expect(firstResult.className).toBe('ProjectSnapshotTest'); + expect(secondResult.className).toBe('ProjectSnapshotTest'); + expect(firstResult.filePath).toBe( + path.join(xcodeProject.baseDir, 'Sources', 'ProjectSnapshotTest.swift'), + ); + expect(fs.readFileSync(firstResult.filePath ?? '', 'utf8')).toContain( + 'final class ProjectSnapshotTest: SnapshotTest', + ); + expect( + xcodeProject + .getSourceFilesForTarget('Project') + ?.filter((filePath) => filePath.endsWith('ProjectSnapshotTest.swift')), + ).toHaveLength(1); + }); + + it('reuses the actual SnapshotTest class name from an existing file', () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'snapshot-test-file-existing-'), + ); + const existingFilePath = path.join(tempDir, 'CustomSnapshots.swift'); + fs.writeFileSync( + existingFilePath, + 'import SnapshottingTests\n\nfinal class CustomSnapshots: SnapshotTest {}\n', + 'utf8', + ); + const addSwiftSourceFileToTarget = vi.fn(); + const xcodeProject = { + addSwiftSourceFileToTarget, + getSourceFilesForTarget: vi.fn(() => [existingFilePath]), + getSynchronizedRootGroupPathsForTarget: vi.fn(() => []), + } as unknown as XcodeProject; + + const result = ensureSnapshotTestFile({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + }); + + expect(result).toEqual({ + changed: false, + className: 'CustomSnapshots', + filePath: existingFilePath, + included: true, + }); + expect(addSwiftSourceFileToTarget).not.toHaveBeenCalled(); + }); + + it('removes a newly created SnapshotTest file when target membership fails', () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'snapshot-test-file-fail-'), + ); + const xcodeProject = { + baseDir: tempDir, + addSwiftSourceFileToTarget: vi.fn(() => ({ + changed: false, + included: false, + })), + getSourceFilesForTarget: vi.fn(() => []), + getSynchronizedRootGroupPathsForTarget: vi.fn(() => []), + } as unknown as XcodeProject; + + const result = ensureSnapshotTestFile({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + }); + + expect(result).toEqual({ changed: false, included: false }); + expect( + fs.existsSync( + path.join(tempDir, 'ProjectTests', 'ProjectTestsSnapshotTest.swift'), + ), + ).toBe(false); + }); + + it('generates Swift-safe type names', () => { + expect(swiftSafeTypeName('123 My-App Tests')).toBe('_123MyAppTests'); + }); +}); diff --git a/test/apple/snapshots/snapshot-verification-scheme.test.ts b/test/apple/snapshots/snapshot-verification-scheme.test.ts new file mode 100644 index 00000000..606c371c --- /dev/null +++ b/test/apple/snapshots/snapshot-verification-scheme.test.ts @@ -0,0 +1,181 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { resolveSnapshotVerificationSchemeName } from '../../../src/apple/snapshots/snapshot-verification-scheme'; + +function createXcodeProject(): string { + const projectDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'snapshot-verification-scheme-'), + ); + const xcodeprojPath = path.join(projectDir, 'App.xcodeproj'); + fs.mkdirSync(xcodeprojPath, { recursive: true }); + return xcodeprojPath; +} + +function writeScheme({ + name, + testTargetNames, + xcodeprojPath, +}: { + name: string; + testTargetNames: string[]; + xcodeprojPath: string; +}): void { + const schemeDirectory = path.join(xcodeprojPath, 'xcshareddata', 'xcschemes'); + fs.mkdirSync(schemeDirectory, { recursive: true }); + fs.writeFileSync( + path.join(schemeDirectory, `${name}.xcscheme`), + getSchemeXml(testTargetNames), + 'utf8', + ); +} + +function writeSchemeManagementPlist({ + schemeNames, + xcodeprojPath, +}: { + schemeNames: string[]; + xcodeprojPath: string; +}): void { + const schemeDirectory = path.join( + xcodeprojPath, + 'xcuserdata', + 'cam.xcuserdatad', + 'xcschemes', + ); + fs.mkdirSync(schemeDirectory, { recursive: true }); + fs.writeFileSync( + path.join(schemeDirectory, 'xcschememanagement.plist'), + getSchemeManagementPlistXml(schemeNames), + 'utf8', + ); +} + +function getSchemeXml(testTargetNames: string[]): string { + return ` + + + +${testTargetNames + .map( + (testTargetName) => ` + + + `, + ) + .join('\n')} + + + +`; +} + +function getSchemeManagementPlistXml(schemeNames: string[]): string { + return ` + + + SchemeUserState + +${schemeNames + .map( + (schemeName) => ` ${schemeName}.xcscheme_^#shared#^_ + + orderHint + 0 + `, + ) + .join('\n')} + + + +`; +} + +describe('resolveSnapshotVerificationSchemeName', () => { + it('returns the explicit scheme that contains the hosted test target', () => { + const xcodeprojPath = createXcodeProject(); + writeScheme({ + name: 'App-Staging', + testTargetNames: ['AppTests'], + xcodeprojPath, + }); + writeScheme({ + name: 'App-Production', + testTargetNames: ['OtherTests'], + xcodeprojPath, + }); + + expect( + resolveSnapshotVerificationSchemeName({ + hostedTestTargetName: 'AppTests', + xcodeprojPath, + }), + ).toBe('App-Staging'); + }); + + it('returns undefined when the only explicit scheme does not contain the hosted test target', () => { + const xcodeprojPath = createXcodeProject(); + writeScheme({ + name: 'App-CI', + testTargetNames: ['OtherTests'], + xcodeprojPath, + }); + + expect( + resolveSnapshotVerificationSchemeName({ + hostedTestTargetName: 'AppTests', + xcodeprojPath, + }), + ).toBeUndefined(); + }); + + it('returns the only managed implicit scheme when no scheme file exists', () => { + const xcodeprojPath = createXcodeProject(); + writeSchemeManagementPlist({ + schemeNames: ['cake'], + xcodeprojPath, + }); + + expect( + resolveSnapshotVerificationSchemeName({ + hostedTestTargetName: 'sausageTests', + xcodeprojPath, + }), + ).toBe('cake'); + }); + + it('returns undefined for ambiguous schemes', () => { + const xcodeprojPath = createXcodeProject(); + writeSchemeManagementPlist({ + schemeNames: ['App-Staging', 'App-Production'], + xcodeprojPath, + }); + + expect( + resolveSnapshotVerificationSchemeName({ + hostedTestTargetName: 'AppTests', + xcodeprojPath, + }), + ).toBeUndefined(); + }); + + it('returns undefined when scheme discovery cannot traverse the project path', () => { + const projectDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'snapshot-verification-scheme-file-'), + ); + const xcodeprojPath = path.join(projectDir, 'App.xcodeproj'); + fs.writeFileSync(xcodeprojPath, '', 'utf8'); + + expect( + resolveSnapshotVerificationSchemeName({ + hostedTestTargetName: 'AppTests', + xcodeprojPath, + }), + ).toBeUndefined(); + }); +}); diff --git a/test/apple/snapshots/snapshotpreviews-xcode-smoke.test.ts b/test/apple/snapshots/snapshotpreviews-xcode-smoke.test.ts new file mode 100644 index 00000000..d7e9f080 --- /dev/null +++ b/test/apple/snapshots/snapshotpreviews-xcode-smoke.test.ts @@ -0,0 +1,261 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { PBXNativeTarget } from 'xcode'; + +import { configureSnapshotPreviewsXcodeProject } from '../../../src/apple/snapshots/configure-snapshotpreviews-xcode-project'; +import { ensureSnapshotTestFile } from '../../../src/apple/snapshots/snapshot-test-file'; +import { + SNAPSHOTPREVIEWS_PACKAGE_URL, + SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT, + SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT, +} from '../../../src/apple/snapshots/snapshotpreviews-package'; +import { XcodeProject } from '../../../src/apple/xcode-manager'; +import { + addHostedUnitTestTarget, + copySingleTargetProjectToTemp, +} from './hosted-test-target-fixture'; + +vi.mock('@clack/prompts', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + step: vi.fn(), + }, +})); + +const projectObjectId = 'D4E604C52D50CEEC00CAB00F'; +const productsGroupId = 'D4E604CE2D50CEEC00CAB00F'; + +const hostedTestTargetIds = { + targetId: 'AAAABBBBCCCCDDDDEEEE0001', + frameworksBuildPhaseId: 'AAAABBBBCCCCDDDDEEEE0002', + sourcesBuildPhaseId: 'AAAABBBBCCCCDDDDEEEE0003', + debugBuildConfigurationId: 'AAAABBBBCCCCDDDDEEEE0004', + releaseBuildConfigurationId: 'AAAABBBBCCCCDDDDEEEE0005', + buildConfigurationListId: 'AAAABBBBCCCCDDDDEEEE0006', + productReferenceId: 'AAAABBBBCCCCDDDDEEEE0007', +}; + +function getTargetByName( + xcodeProject: XcodeProject, + targetName: string, +): PBXNativeTarget { + const target = Object.values(xcodeProject.objects.PBXNativeTarget ?? {}).find( + (candidate) => { + return typeof candidate !== 'string' && candidate.name === targetName; + }, + ) as PBXNativeTarget | undefined; + + if (!target) { + throw new Error(`Target not found: ${targetName}`); + } + + return target; +} + +function getProductDependencyIds( + xcodeProject: XcodeProject, + productName: string, +): string[] { + return Object.entries( + xcodeProject.objects.XCSwiftPackageProductDependency ?? {}, + ).reduce((ids, [id, productDependency]) => { + if ( + id.endsWith('_comment') || + typeof productDependency === 'string' || + productDependency.productName !== productName + ) { + return ids; + } + + return ids.concat(id); + }, new Array()); +} + +function getProductDependency( + xcodeProject: XcodeProject, + productDependencyId: string, +) { + const productDependency = + xcodeProject.objects.XCSwiftPackageProductDependency?.[productDependencyId]; + if (!productDependency || typeof productDependency === 'string') { + throw new Error(`Product dependency not found: ${productDependencyId}`); + } + + return productDependency; +} + +function getFrameworkProductRefs( + xcodeProject: XcodeProject, + targetName: string, +): string[] { + return (getTargetByName(xcodeProject, targetName).buildPhases ?? []).reduce( + (productRefs, buildPhaseReference) => { + const buildPhase = + xcodeProject.objects.PBXFrameworksBuildPhase?.[ + buildPhaseReference.value + ]; + if (!buildPhase || typeof buildPhase === 'string') { + return productRefs; + } + + return productRefs.concat( + (buildPhase.files ?? []).flatMap((file) => { + const buildFile = xcodeProject.objects.PBXBuildFile?.[file.value]; + return buildFile && + typeof buildFile !== 'string' && + typeof buildFile.productRef === 'string' + ? [buildFile.productRef] + : []; + }), + ); + }, + new Array(), + ); +} + +function getSnapshotPreviewsPackageReferenceIds( + xcodeProject: XcodeProject, +): string[] { + return Object.entries( + xcodeProject.objects.XCRemoteSwiftPackageReference ?? {}, + ).reduce((ids, [id, packageReference]) => { + if ( + id.endsWith('_comment') || + typeof packageReference === 'string' || + packageReference.repositoryURL !== `"${SNAPSHOTPREVIEWS_PACKAGE_URL}"` + ) { + return ids; + } + + return ids.concat(id); + }, new Array()); +} + +describe('SnapshotPreviews hosted XCTest Xcode project smoke coverage', () => { + it('mutates, reparses, and reruns without duplicating package or source membership', () => { + const pbxprojPath = copySingleTargetProjectToTemp( + 'snapshotpreviews-xcode-smoke-', + ); + const xcodeProject = new XcodeProject(pbxprojPath); + addHostedUnitTestTarget(xcodeProject, { + ids: hostedTestTargetIds, + includeSourcesBuildPhase: true, + projectObjectId, + productsGroupId, + }); + + const snapshotTestResult = ensureSnapshotTestFile({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + }); + const packageResult = configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + previewTargetNames: ['Project'], + }); + xcodeProject.write(); + + expect(snapshotTestResult.changed).toBe(true); + expect(snapshotTestResult.included).toBe(true); + if (!snapshotTestResult.included) { + throw new Error('Expected snapshot test file to be included'); + } + expect(snapshotTestResult.filePath).toBe( + path.join( + xcodeProject.baseDir, + 'ProjectTests', + 'ProjectTestsSnapshotTest.swift', + ), + ); + expect(packageResult).toEqual({ + changed: true, + failedSnapshotPreferencesTargetNames: [], + linked: true, + }); + + const firstSerializedProject = fs.readFileSync(pbxprojPath, 'utf8'); + const reparsedProject = new XcodeProject(pbxprojPath); + + expect(reparsedProject.getUnitTestTargetNames()).toContain('ProjectTests'); + expect( + getSnapshotPreviewsPackageReferenceIds(reparsedProject), + ).toHaveLength(1); + + const snapshottingTestsProductDependencyIds = getProductDependencyIds( + reparsedProject, + SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT, + ); + const snapshotPreferencesProductDependencyIds = getProductDependencyIds( + reparsedProject, + SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT, + ); + expect(snapshottingTestsProductDependencyIds).toHaveLength(1); + expect(snapshotPreferencesProductDependencyIds).toHaveLength(1); + expect( + getProductDependency( + reparsedProject, + snapshottingTestsProductDependencyIds[0], + ).package, + ).toBe(getSnapshotPreviewsPackageReferenceIds(reparsedProject)[0]); + expect( + getProductDependency( + reparsedProject, + snapshotPreferencesProductDependencyIds[0], + ).package, + ).toBe(getSnapshotPreviewsPackageReferenceIds(reparsedProject)[0]); + expect(getFrameworkProductRefs(reparsedProject, 'ProjectTests')).toEqual([ + snapshottingTestsProductDependencyIds[0], + ]); + expect(getFrameworkProductRefs(reparsedProject, 'Project')).toEqual([ + snapshotPreferencesProductDependencyIds[0], + ]); + + expect( + getTargetByName(reparsedProject, 'ProjectTests') + .packageProductDependencies, + ).toEqual([ + { + value: snapshottingTestsProductDependencyIds[0], + comment: SNAPSHOTPREVIEWS_SNAPSHOT_TESTS_PRODUCT, + }, + ]); + expect( + getTargetByName(reparsedProject, 'Project').packageProductDependencies, + ).toEqual([ + { + value: snapshotPreferencesProductDependencyIds[0], + comment: SNAPSHOTPREVIEWS_PREFERENCES_PRODUCT, + }, + ]); + expect( + reparsedProject + .getSourceFilesForTarget('ProjectTests') + ?.filter((sourceFilePath) => + sourceFilePath.endsWith( + 'ProjectTests/ProjectTestsSnapshotTest.swift', + ), + ), + ).toHaveLength(1); + + const secondSnapshotTestResult = ensureSnapshotTestFile({ + xcodeProject: reparsedProject, + hostedTestTargetName: 'ProjectTests', + }); + const secondPackageResult = configureSnapshotPreviewsXcodeProject({ + xcodeProject: reparsedProject, + hostedTestTargetName: 'ProjectTests', + previewTargetNames: ['Project'], + }); + reparsedProject.write(); + + expect(secondSnapshotTestResult.changed).toBe(false); + expect(secondPackageResult).toEqual({ + changed: false, + failedSnapshotPreferencesTargetNames: [], + linked: true, + }); + expect(fs.readFileSync(pbxprojPath, 'utf8')).toBe(firstSerializedProject); + }, 15_000); +}); diff --git a/test/apple/snapshots/snapshots-cli-preflight.test.ts b/test/apple/snapshots/snapshots-cli-preflight.test.ts new file mode 100644 index 00000000..03109877 --- /dev/null +++ b/test/apple/snapshots/snapshots-cli-preflight.test.ts @@ -0,0 +1,231 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + askToInstallSentryCLI: vi.fn(), + executeSync: vi.fn(), + hasSentryCLI: vi.fn(), + info: vi.fn(), + installSentryCLI: vi.fn(), + setTag: vi.fn(), + success: vi.fn(), + warn: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + default: { + log: { + info: mocks.info, + success: mocks.success, + warn: mocks.warn, + }, + }, +})); + +vi.mock('@sentry/node', () => ({ + setTag: mocks.setTag, +})); + +vi.mock('../../../src/telemetry', () => ({ + traceStep: async (_name: string, callback: () => Promise) => + await callback(), +})); + +vi.mock('../../../src/utils/bash', () => ({ + executeSync: mocks.executeSync, + hasSentryCLI: mocks.hasSentryCLI, + installSentryCLI: mocks.installSentryCLI, +})); + +vi.mock('../../../src/utils/clack', () => ({ + askToInstallSentryCLI: mocks.askToInstallSentryCLI, +})); + +import { + checkInstalledCLISnapshots, + getSnapshotFastlaneGuidance, +} from '../../../src/apple/snapshots/snapshots-cli-preflight'; + +function getVerificationGuidance(projectDir: string) { + return { + appId: 'com.getsentry.App', + hostedTestTargetName: 'AppTests', + projectDir, + projectPath: path.join(projectDir, 'App.xcodeproj'), + schemeName: 'App', + snapshotTestClassName: 'AppTestsSnapshotTest', + }; +} + +describe('snapshots CLI preflight', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('offers to install sentry-cli with snapshots-aware warning when missing', async () => { + mocks.hasSentryCLI.mockReturnValue(false); + mocks.askToInstallSentryCLI.mockResolvedValue(false); + + const projectDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'snapshot-cli-missing-'), + ); + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance: getVerificationGuidance(projectDir), + }); + + expect(mocks.askToInstallSentryCLI).toHaveBeenCalledTimes(1); + expect(mocks.warn).toHaveBeenCalledWith( + expect.stringContaining('upload snapshots to Sentry'), + ); + expect(mocks.executeSync).not.toHaveBeenCalled(); + }); + + it('verifies snapshots upload support and prefers existing Fastlane guidance', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-cli-')); + const fastlaneDir = path.join(projectDir, 'fastlane'); + fs.mkdirSync(fastlaneDir); + fs.writeFileSync( + path.join(fastlaneDir, 'Fastfile'), + 'platform :ios do\n lane :upload_sentry_snapshots do |options|\n sentry_upload_snapshots(path: options[:path])\n end\nend\n', + ); + mocks.hasSentryCLI.mockReturnValue(true); + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance: getVerificationGuidance(projectDir), + }); + + expect(mocks.executeSync).toHaveBeenCalledWith( + 'sentry-cli snapshots upload --help', + ); + expect(mocks.success).toHaveBeenCalledWith( + 'sentry-cli snapshots upload is available.', + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('xcodebuild test'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('-project App.xcodeproj'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('-scheme App'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('-only-testing:AppTests/AppTestsSnapshotTest'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining( + 'bundle exec fastlane ios upload_sentry_snapshots', + ), + ); + }); + + it('prints xcodebuild export and sentry-cli upload guidance', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-cli-')); + mocks.hasSentryCLI.mockReturnValue(true); + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance: getVerificationGuidance(projectDir), + }); + + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('TEST_RUNNER_SNAPSHOTS_EXPORT_DIR'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('-only-testing:AppTests/AppTestsSnapshotTest'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('sentry-cli snapshots upload'), + ); + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('--app-id com.getsentry.App'), + ); + }); + + it('uses the provided snapshot test class name in xcodebuild guidance', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-cli-')); + mocks.hasSentryCLI.mockReturnValue(true); + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance: { + ...getVerificationGuidance(projectDir), + snapshotTestClassName: 'CustomSnapshots', + }, + }); + + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('-only-testing:AppTests/CustomSnapshots'), + ); + expect(mocks.info).not.toHaveBeenCalledWith( + expect.stringContaining('-only-testing:AppTests/AppTestsSnapshotTest'), + ); + }); + + it('uses a scheme placeholder when no verification scheme was detected', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-cli-')); + mocks.hasSentryCLI.mockReturnValue(true); + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance: { + ...getVerificationGuidance(projectDir), + schemeName: undefined, + }, + }); + + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('-scheme '), + ); + expect(mocks.info).not.toHaveBeenCalledWith( + expect.stringContaining("-scheme ''"), + ); + }); + + it('uses an explicit app id placeholder when the bundle identifier is unavailable', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'snapshot-cli-')); + const verificationGuidance = { + ...getVerificationGuidance(projectDir), + appId: undefined, + }; + mocks.hasSentryCLI.mockReturnValue(true); + + await checkInstalledCLISnapshots({ + projectDir, + verificationGuidance, + }); + + expect(mocks.info).toHaveBeenCalledWith( + expect.stringContaining('--app-id YOUR_APP_BUNDLE_ID'), + ); + expect(mocks.info).not.toHaveBeenCalledWith( + expect.stringContaining('com.example.MyApp'), + ); + expect(mocks.info).not.toHaveBeenCalledWith( + expect.stringContaining(''), + ); + }); + + it('detects existing Fastlane snapshots upload support', () => { + const projectDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'snapshot-fastlane-'), + ); + const fastlaneDir = path.join(projectDir, 'fastlane'); + fs.mkdirSync(fastlaneDir); + fs.writeFileSync( + path.join(fastlaneDir, 'Fastfile'), + 'lane :upload_sentry_snapshots do\n sentry_upload_snapshots(path: "snapshot-images")\nend\n', + ); + + expect(getSnapshotFastlaneGuidance(projectDir)).toEqual({ + fastfilePath: path.join(fastlaneDir, 'Fastfile'), + hasUploadLane: true, + hasSentryUploadAction: true, + }); + }); +}); diff --git a/test/apple/snapshots/source-file-insertion.test.ts b/test/apple/snapshots/source-file-insertion.test.ts new file mode 100644 index 00000000..2720b902 --- /dev/null +++ b/test/apple/snapshots/source-file-insertion.test.ts @@ -0,0 +1,81 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PBXSourcesBuildPhase } from 'xcode'; + +import { XcodeProject } from '../../../src/apple/xcode-manager'; +import { copySingleTargetProjectToTemp } from './hosted-test-target-fixture'; + +vi.mock('@clack/prompts', () => ({ + log: { + info: vi.fn(), + success: vi.fn(), + step: vi.fn(), + }, +})); + +function sourceBuildPhaseFiles(xcodeProject: XcodeProject): unknown[] { + const phase = xcodeProject.objects.PBXSourcesBuildPhase?.[ + 'D4E604C92D50CEEC00CAB00F' + ] as PBXSourcesBuildPhase | undefined; + return phase?.files ?? []; +} + +describe('XcodeProject Swift source-file insertion', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('adds a Swift source file to a classic PBXSourcesBuildPhase once', () => { + const xcodeProject = new XcodeProject( + copySingleTargetProjectToTemp('snapshot-source-file-'), + ); + const filePath = path.join( + xcodeProject.baseDir, + 'ProjectTests', + 'SnapshotTest.swift', + ); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, 'import SnapshottingTests\n', 'utf8'); + + const firstResult = xcodeProject.addSwiftSourceFileToTarget({ + targetName: 'Project', + filePath, + }); + const secondResult = xcodeProject.addSwiftSourceFileToTarget({ + targetName: 'Project', + filePath, + }); + + expect(firstResult).toEqual({ changed: true, included: true }); + expect(secondResult).toEqual({ changed: false, included: true }); + expect(sourceBuildPhaseFiles(xcodeProject)).toHaveLength(1); + expect( + Object.values(xcodeProject.objects.PBXFileReference ?? {}).filter( + (fileReference) => + typeof fileReference === 'object' && + fileReference.path === 'ProjectTests/SnapshotTest.swift', + ), + ).toHaveLength(1); + }); + + it('uses synchronized root group inclusion when the file is inside a target synchronized folder', () => { + const xcodeProject = new XcodeProject( + copySingleTargetProjectToTemp('snapshot-source-file-'), + ); + const filePath = path.join( + xcodeProject.baseDir, + 'Sources', + 'SnapshotTest.swift', + ); + fs.writeFileSync(filePath, 'import SnapshottingTests\n', 'utf8'); + + const result = xcodeProject.addSwiftSourceFileToTarget({ + targetName: 'Project', + filePath, + }); + + expect(result).toEqual({ changed: false, included: true }); + expect(sourceBuildPhaseFiles(xcodeProject)).toEqual([]); + }); +}); diff --git a/test/apple/xcode-manager.test.ts b/test/apple/xcode-manager.test.ts index 1cb3bf87..7c3ef3d4 100644 --- a/test/apple/xcode-manager.test.ts +++ b/test/apple/xcode-manager.test.ts @@ -8,6 +8,10 @@ import type { PBXShellScriptBuildPhase, XCBuildConfiguration, } from 'xcode'; +import { + configureSnapshotPreviewsXcodeProject, + snapshottingTestsProductSpec, +} from '../../src/apple/snapshots/configure-snapshotpreviews-xcode-project'; import { SENTRY_SPM_ALREADY_LINKED_FRAMEWORK_COMMENT, sentrySwiftPackageProductSpec, @@ -15,11 +19,13 @@ import { import { getRunScriptTemplate } from '../../src/apple/templates'; import { SwiftPackageProductLinkOptions, - type SwiftPackageProductSpec, XcodeProject, } from '../../src/apple/xcode-manager'; import type { SentryProjectData } from '../../src/utils/types'; -import { addHostedUnitTestTarget } from './snapshots/hosted-test-target-fixture'; +import { + addHostedUnitTestTarget, + type HostedTestTargetFixtureIds, +} from './snapshots/hosted-test-target-fixture'; vi.mock('node:fs', async () => ({ __esModule: true, @@ -81,17 +87,6 @@ const sentrySwiftPackageProductLinkOptions: SwiftPackageProductLinkOptions = { }; const projectObjectId = 'D4E604C52D50CEEC00CAB00F'; const productsGroupId = 'D4E604CE2D50CEEC00CAB00F'; -const genericSwiftPackageProductSpec: SwiftPackageProductSpec = { - package: { - repositoryURL: 'https://github.com/example/example-package/', - requirement: { - kind: 'upToNextMajorVersion', - minimumVersion: '1.2.3', - }, - commentName: 'ExamplePackage', - }, - productName: 'ExampleProduct', -}; function objectKeysWithoutComments(object: unknown): string[] { return Object.keys((object ?? {}) as Record).filter( @@ -203,18 +198,118 @@ describe('XcodeManager', () => { }); }); + describe('target type detection', () => { + it('lists application and unit-test targets by name', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + projectObjectId, + productsGroupId, + }); + + // -- Act & Assert -- + expect(xcodeProject.getAllTargets()).toEqual(['Project']); + expect(xcodeProject.getUnitTestTargetNames()).toEqual(['ProjectTests']); + }); + + it('returns multiple unit-test targets by name', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + name: 'ProjectTests', + projectObjectId, + productsGroupId, + }); + addHostedUnitTestTarget(xcodeProject, { + name: 'ProjectSnapshotTests', + projectObjectId, + productsGroupId, + }); + + // -- Act & Assert -- + expect(xcodeProject.getUnitTestTargetNames()).toEqual([ + 'ProjectTests', + 'ProjectSnapshotTests', + ]); + }); + + it('returns only unit-test targets hosted by the selected application target', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + name: 'ProjectTests', + projectObjectId, + productsGroupId, + }); + addHostedUnitTestTarget(xcodeProject, { + hostAppName: 'OtherApp', + name: 'OtherAppTests', + projectObjectId, + productsGroupId, + }); + + // -- Act & Assert -- + expect( + xcodeProject.getHostedUnitTestTargetNamesForApplicationTarget( + 'Project', + ), + ).toEqual(['ProjectTests']); + }); + + it('matches hosted unit-test targets by app product name', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + name: 'ProjectTests', + projectObjectId, + productsGroupId, + testHost: + '"$(BUILT_PRODUCTS_DIR)/SentryWizardSampleBlank.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/SentryWizardSampleBlank"', + }); + + // -- Act & Assert -- + expect( + xcodeProject.getHostedUnitTestTargetNamesForApplicationTarget( + 'Project', + ), + ).toEqual(['ProjectTests']); + }); + + it('returns no hosted unit-test targets for an unknown application target', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + name: 'ProjectTests', + projectObjectId, + productsGroupId, + }); + + // -- Act & Assert -- + expect( + xcodeProject.getHostedUnitTestTargetNamesForApplicationTarget( + 'MissingApp', + ), + ).toEqual([]); + }); + }); + describe('ensureSwiftPackageProductLinked', () => { let xcodeProject: XcodeProject; + let hostedTestTargetIds: HostedTestTargetFixtureIds; beforeEach(() => { xcodeProject = new XcodeProject(singleTargetProjectPath); + hostedTestTargetIds = addHostedUnitTestTarget(xcodeProject, { + projectObjectId, + productsGroupId, + }); }); - it('links a Swift package product to the selected target', () => { + it('links a Swift package product only to the selected target', () => { // -- Act -- const result = xcodeProject.ensureSwiftPackageProductLinked( - 'Project', - genericSwiftPackageProductSpec, + 'ProjectTests', + snapshottingTestsProductSpec, ); // -- Assert -- @@ -230,22 +325,33 @@ describe('XcodeManager', () => { ), ).toHaveLength(1); - const target = getTargetByName(xcodeProject, 'Project'); - expect(target.packageProductDependencies).toEqual([ - expect.objectContaining({ comment: 'ExampleProduct' }), + const appTarget = getTargetByName(xcodeProject, 'Project'); + const hostedTestTarget = getTargetByName(xcodeProject, 'ProjectTests'); + expect(appTarget.packageProductDependencies).toEqual([]); + expect(hostedTestTarget.packageProductDependencies).toEqual([ + expect.objectContaining({ comment: 'SnapshottingTests' }), ]); - const frameworksBuildPhase = + const appFrameworksBuildPhase = xcodeProject.objects.PBXFrameworksBuildPhase?.[ appFrameworksBuildPhaseId ]; + const testFrameworksBuildPhase = + xcodeProject.objects.PBXFrameworksBuildPhase?.[ + hostedTestTargetIds.frameworksBuildPhaseId + ]; expect( - typeof frameworksBuildPhase !== 'string' - ? frameworksBuildPhase?.files + typeof appFrameworksBuildPhase !== 'string' + ? appFrameworksBuildPhase?.files + : undefined, + ).toEqual([]); + expect( + typeof testFrameworksBuildPhase !== 'string' + ? testFrameworksBuildPhase?.files : undefined, ).toEqual([ expect.objectContaining({ - comment: 'ExampleProduct in Frameworks', + comment: 'SnapshottingTests in Frameworks', }), ]); }); @@ -253,12 +359,12 @@ describe('XcodeManager', () => { it('is idempotent across repeated runs', () => { // -- Act -- const firstResult = xcodeProject.ensureSwiftPackageProductLinked( - 'Project', - genericSwiftPackageProductSpec, + 'ProjectTests', + snapshottingTestsProductSpec, ); const secondResult = xcodeProject.ensureSwiftPackageProductLinked( - 'Project', - genericSwiftPackageProductSpec, + 'ProjectTests', + snapshottingTestsProductSpec, ); // -- Assert -- @@ -286,25 +392,25 @@ describe('XcodeManager', () => { xcodeProject.objects.XCRemoteSwiftPackageReference = { [packageRefId]: { isa: 'XCRemoteSwiftPackageReference', - repositoryURL: `"${genericSwiftPackageProductSpec.package.repositoryURL}"`, - requirement: genericSwiftPackageProductSpec.package.requirement, + repositoryURL: `"${snapshottingTestsProductSpec.package.repositoryURL}"`, + requirement: snapshottingTestsProductSpec.package.requirement, }, [`${packageRefId}_comment`]: - 'XCRemoteSwiftPackageReference "ExamplePackage"', + 'XCRemoteSwiftPackageReference "SnapshotPreviews"', }; xcodeProject.objects.XCSwiftPackageProductDependency = { [productDependencyId]: { isa: 'XCSwiftPackageProductDependency', package: packageRefId, - productName: '"ExampleProduct"', + productName: '"SnapshottingTests"', }, - [`${productDependencyId}_comment`]: 'ExampleProduct', + [`${productDependencyId}_comment`]: 'SnapshottingTests', }; // -- Act -- const result = xcodeProject.ensureSwiftPackageProductLinked( - 'Project', - genericSwiftPackageProductSpec, + 'ProjectTests', + snapshottingTestsProductSpec, ); // -- Assert -- @@ -314,7 +420,7 @@ describe('XcodeManager', () => { xcodeProject.objects.XCSwiftPackageProductDependency, ), ).toEqual([productDependencyId]); - expect(getTargetByName(xcodeProject, 'Project')).toEqual( + expect(getTargetByName(xcodeProject, 'ProjectTests')).toEqual( expect.objectContaining({ packageProductDependencies: [ expect.objectContaining({ value: productDependencyId }), @@ -328,20 +434,20 @@ describe('XcodeManager', () => { xcodeProject.objects.XCRemoteSwiftPackageReference = { EXISTINGPACKAGE000000000001: { isa: 'XCRemoteSwiftPackageReference', - repositoryURL: `"${genericSwiftPackageProductSpec.package.repositoryURL}/"`, + repositoryURL: `"${snapshottingTestsProductSpec.package.repositoryURL}/"`, requirement: { kind: 'upToNextMajorVersion', - minimumVersion: '1.0.0', + minimumVersion: '0.8.0', }, }, EXISTINGPACKAGE000000000001_comment: - 'XCRemoteSwiftPackageReference "ExamplePackage"', + 'XCRemoteSwiftPackageReference "SnapshotPreviews"', }; // -- Act -- const result = xcodeProject.ensureSwiftPackageProductLinked( - 'Project', - genericSwiftPackageProductSpec, + 'ProjectTests', + snapshottingTestsProductSpec, ); // -- Assert -- @@ -357,7 +463,7 @@ describe('XcodeManager', () => { ], ).toEqual( expect.objectContaining({ - requirement: genericSwiftPackageProductSpec.package.requirement, + requirement: snapshottingTestsProductSpec.package.requirement, }), ); expect( @@ -369,20 +475,20 @@ describe('XcodeManager', () => { ).toEqual( expect.objectContaining({ package: 'EXISTINGPACKAGE000000000001', - productName: 'ExampleProduct', + productName: 'SnapshottingTests', }), ); }); it('does not partially mutate package state when the target has no Frameworks build phase', () => { // -- Arrange -- - const target = getTargetByName(xcodeProject, 'Project'); - target.buildPhases = []; + const hostedTestTarget = getTargetByName(xcodeProject, 'ProjectTests'); + hostedTestTarget.buildPhases = []; // -- Act -- const result = xcodeProject.ensureSwiftPackageProductLinked( - 'Project', - genericSwiftPackageProductSpec, + 'ProjectTests', + snapshottingTestsProductSpec, ); // -- Assert -- @@ -394,7 +500,161 @@ describe('XcodeManager', () => { xcodeProject.objects.XCSwiftPackageProductDependency, ).toBeUndefined(); expect(xcodeProject.objects.PBXBuildFile).toBeUndefined(); - expect(target.packageProductDependencies).toEqual([]); + expect(hostedTestTarget.packageProductDependencies).toEqual([]); + }); + }); + + describe('configureSnapshotPreviewsXcodeProject', () => { + it('links SnapshottingTests to the hosted test target and SnapshotPreferences only to selected preview targets', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + projectObjectId, + productsGroupId, + }); + + // -- Act -- + const result = configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + previewTargetNames: ['Project'], + }); + + // -- Assert -- + expect(result).toEqual({ + changed: true, + failedSnapshotPreferencesTargetNames: [], + linked: true, + }); + const appTarget = getTargetByName(xcodeProject, 'Project'); + const hostedTestTarget = getTargetByName(xcodeProject, 'ProjectTests'); + expect(appTarget.packageProductDependencies).toEqual([ + expect.objectContaining({ comment: 'SnapshotPreferences' }), + ]); + expect(hostedTestTarget.packageProductDependencies).toEqual([ + expect.objectContaining({ comment: 'SnapshottingTests' }), + ]); + expect( + objectKeysWithoutComments( + xcodeProject.objects.XCSwiftPackageProductDependency, + ), + ).toHaveLength(2); + }); + + it('does not link SnapshotPreferences when no preview targets are selected', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + projectObjectId, + productsGroupId, + }); + + // -- Act -- + configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + }); + + // -- Assert -- + const appTarget = getTargetByName(xcodeProject, 'Project'); + const hostedTestTarget = getTargetByName(xcodeProject, 'ProjectTests'); + expect(appTarget.packageProductDependencies).toEqual([]); + expect(hostedTestTarget.packageProductDependencies).toEqual([ + expect.objectContaining({ comment: 'SnapshottingTests' }), + ]); + expect( + objectKeysWithoutComments( + xcodeProject.objects.XCSwiftPackageProductDependency, + ), + ).toHaveLength(1); + }); + + it('continues when SnapshotPreferences cannot be linked to a preview target', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + addHostedUnitTestTarget(xcodeProject, { + projectObjectId, + productsGroupId, + }); + const appTarget = getTargetByName(xcodeProject, 'Project'); + appTarget.buildPhases = []; + + // -- Act -- + const result = configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName: 'ProjectTests', + previewTargetNames: ['Project'], + }); + + // -- Assert -- + const hostedTestTarget = getTargetByName(xcodeProject, 'ProjectTests'); + expect(result).toEqual({ + changed: true, + failedSnapshotPreferencesTargetNames: ['Project'], + linked: true, + }); + expect(appTarget.packageProductDependencies).toEqual([]); + expect(hostedTestTarget.packageProductDependencies).toEqual([ + expect.objectContaining({ comment: 'SnapshottingTests' }), + ]); + expect( + objectKeysWithoutComments( + xcodeProject.objects.XCSwiftPackageProductDependency, + ), + ).toHaveLength(1); + }); + + it('does not mutate package state when the hosted test target is missing', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + + // -- Act -- + const result = configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName: 'MissingTests', + }); + + // -- Assert -- + expect(result).toEqual({ + changed: false, + failedSnapshotPreferencesTargetNames: [], + linked: false, + }); + expect( + xcodeProject.objects.XCRemoteSwiftPackageReference, + ).toBeUndefined(); + expect( + xcodeProject.objects.XCSwiftPackageProductDependency, + ).toBeUndefined(); + expect(xcodeProject.objects.PBXBuildFile).toBeUndefined(); + }); + + it('does not link SnapshotPreferences when the hosted test target is missing', () => { + // -- Arrange -- + const xcodeProject = new XcodeProject(singleTargetProjectPath); + + // -- Act -- + const result = configureSnapshotPreviewsXcodeProject({ + xcodeProject, + hostedTestTargetName: 'MissingTests', + previewTargetNames: ['Project'], + }); + + // -- Assert -- + const appTarget = getTargetByName(xcodeProject, 'Project'); + expect(result).toEqual({ + changed: false, + failedSnapshotPreferencesTargetNames: [], + linked: false, + }); + expect(appTarget.packageProductDependencies).toEqual([]); + expect( + xcodeProject.objects.XCRemoteSwiftPackageReference, + ).toBeUndefined(); + expect( + xcodeProject.objects.XCSwiftPackageProductDependency, + ).toBeUndefined(); + expect(xcodeProject.objects.PBXBuildFile).toBeUndefined(); }); }); diff --git a/test/constants.test.ts b/test/constants.test.ts new file mode 100644 index 00000000..7a6b03b4 --- /dev/null +++ b/test/constants.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { + getIntegrationDescription, + Integration, + mapIntegrationToPlatform, +} from '../lib/Constants'; + +describe('Constants', () => { + it('exposes Apple Snapshots as a selectable integration', () => { + expect(Object.keys(Integration)).toContain('appleSnapshots'); + expect(getIntegrationDescription(Integration.appleSnapshots)).toBe( + 'Apple Snapshots', + ); + expect(mapIntegrationToPlatform(Integration.appleSnapshots)).toBe('iOS'); + }); +}); diff --git a/test/run.test.ts b/test/run.test.ts new file mode 100644 index 00000000..5a62676c --- /dev/null +++ b/test/run.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const wizardMocks = vi.hoisted(() => ({ + legacyRun: vi.fn(), + readEnvironment: vi.fn(() => ({})), + runAndroidWizard: vi.fn(), + runAngularWizard: vi.fn(), + runAppleSnapshotsWizard: vi.fn(), + runAppleWizard: vi.fn(), + runCloudflareWizard: vi.fn(), + runFlutterWizard: vi.fn(), + runNextjsWizard: vi.fn(), + runNuxtWizard: vi.fn(), + runReactNativeWizard: vi.fn(), + runReactRouterWizard: vi.fn(), + runRemixWizard: vi.fn(), + runSourcemapsWizard: vi.fn(), + runSvelteKitWizard: vi.fn(), +})); + +vi.mock('@clack/prompts', () => ({ + intro: vi.fn(), + log: { + error: vi.fn(), + }, + outro: vi.fn(), + select: vi.fn(), +})); + +vi.mock('../lib/Helper/Env', () => ({ + readEnvironment: wizardMocks.readEnvironment, +})); + +vi.mock('../lib/Setup', () => ({ + run: wizardMocks.legacyRun, +})); + +vi.mock('../src/android/android-wizard', () => ({ + runAndroidWizard: wizardMocks.runAndroidWizard, +})); + +vi.mock('../src/angular/angular-wizard', () => ({ + runAngularWizard: wizardMocks.runAngularWizard, +})); + +vi.mock('../src/apple/apple-wizard', () => ({ + runAppleWizard: wizardMocks.runAppleWizard, +})); + +vi.mock('../src/apple/snapshots/apple-snapshots-wizard', () => ({ + runAppleSnapshotsWizard: wizardMocks.runAppleSnapshotsWizard, +})); + +vi.mock('../src/cloudflare/cloudflare-wizard', () => ({ + runCloudflareWizard: wizardMocks.runCloudflareWizard, +})); + +vi.mock('../src/flutter/flutter-wizard', () => ({ + runFlutterWizard: wizardMocks.runFlutterWizard, +})); + +vi.mock('../src/nextjs/nextjs-wizard', () => ({ + runNextjsWizard: wizardMocks.runNextjsWizard, +})); + +vi.mock('../src/nuxt/nuxt-wizard', () => ({ + runNuxtWizard: wizardMocks.runNuxtWizard, +})); + +vi.mock('../src/react-native/react-native-wizard', () => ({ + runReactNativeWizard: wizardMocks.runReactNativeWizard, +})); + +vi.mock('../src/react-router/react-router-wizard', () => ({ + runReactRouterWizard: wizardMocks.runReactRouterWizard, +})); + +vi.mock('../src/remix/remix-wizard', () => ({ + runRemixWizard: wizardMocks.runRemixWizard, +})); + +vi.mock('../src/sourcemaps/sourcemaps-wizard', () => ({ + runSourcemapsWizard: wizardMocks.runSourcemapsWizard, +})); + +vi.mock('../src/sveltekit/sveltekit-wizard', () => ({ + runSvelteKitWizard: wizardMocks.runSvelteKitWizard, +})); + +vi.mock('../src/utils/debug', () => ({ + enableDebugLogs: vi.fn(), +})); + +vi.mock('../src/utils/clack', () => ({ + abortIfCancelled: async (input: T | Promise): Promise => await input, +})); + +import { run } from '../src/run'; + +type RunArgs = Parameters[0]; + +function getBaseArgs(integration: RunArgs['integration']): RunArgs { + return { + integration, + uninstall: false, + signup: false, + skipConnect: false, + debug: false, + quiet: false, + disableTelemetry: true, + }; +} + +describe('run', () => { + beforeEach(() => { + vi.clearAllMocks(); + wizardMocks.readEnvironment.mockReturnValue({}); + }); + + it('routes appleSnapshots to the Apple Snapshots wizard with the Xcode project directory', async () => { + await run({ + ...getBaseArgs('appleSnapshots'), + xcodeProjectDir: '/tmp/MyApp', + }); + + expect(wizardMocks.runAppleSnapshotsWizard).toHaveBeenCalledWith( + expect.objectContaining({ + telemetryEnabled: false, + projectDir: '/tmp/MyApp', + }), + ); + expect(wizardMocks.runAppleWizard).not.toHaveBeenCalled(); + }); + + it('keeps ios routing on the existing Apple wizard', async () => { + await run({ + ...getBaseArgs('ios'), + xcodeProjectDir: '/tmp/MyApp', + }); + + expect(wizardMocks.runAppleWizard).toHaveBeenCalledWith( + expect.objectContaining({ + telemetryEnabled: false, + projectDir: '/tmp/MyApp', + }), + ); + expect(wizardMocks.runAppleSnapshotsWizard).not.toHaveBeenCalled(); + }); +});