diff --git a/e2e-tests/tests/flutter.test.ts b/e2e-tests/tests/flutter.test.ts index d9392f74f..4f2c8ddbc 100644 --- a/e2e-tests/tests/flutter.test.ts +++ b/e2e-tests/tests/flutter.test.ts @@ -1,4 +1,5 @@ import * as fs from 'node:fs'; +import * as path from 'node:path'; import { Integration } from '../../lib/Constants'; import { createIsolatedTestEnv, getWizardCommand } from '../utils'; import { @@ -159,4 +160,90 @@ describe('Flutter', () => { ); }); }); + + describe('with CRLF line endings', () => { + let wizardExitCode: number; + const { projectDir, cleanup } = createIsolatedTestEnv('flutter-test-app'); + + function convertToCrlf(dir: string) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + convertToCrlf(fullPath); + } else if ( + /\.(yaml|dart|properties)$/.test(entry.name) || + entry.name === '.gitignore' + ) { + const content = fs.readFileSync(fullPath, 'utf-8'); + fs.writeFileSync( + fullPath, + content.replace(/\r?\n/g, '\r\n'), + 'utf-8', + ); + } + } + } + + beforeAll(async () => { + convertToCrlf(projectDir); + + wizardExitCode = await withEnv({ + cwd: projectDir, + debug: true, + }) + .defineInteraction() + .whenAsked('Do you want to continue anyway?') + .respondWith(KEYS.ENTER) + .expectOutput( + 'The Sentry Flutter Wizard will help you set up Sentry for your application', + ) + .whenAsked('Do you want to enable Tracing') + .respondWith(KEYS.ENTER) + .whenAsked( + 'to analyze CPU usage and optimize performance-critical code on iOS & macOS?', + ) + .respondWith(KEYS.ENTER) + .whenAsked('to record user interactions and debug issues?') + .respondWith(KEYS.ENTER) + .whenAsked('to send your application logs to Sentry?') + .respondWith(KEYS.ENTER) + .whenAsked( + 'Optionally add a project-scoped MCP server configuration for the Sentry MCP?', + ) + .respondWith(KEYS.DOWN, KEYS.ENTER) + .expectOutput('Successfully installed the Sentry Flutter SDK!') + .run(getWizardCommand(Integration.flutter)); + }); + + afterAll(() => { + cleanup(); + }); + + test('exits with exit code 0', () => { + expect(wizardExitCode).toBe(0); + }); + + test('modified files preserve CRLF line endings', () => { + const textFiles = fs + .readdirSync(projectDir, { recursive: true, encoding: 'utf-8' }) + .filter( + (f) => + /\.(yaml|dart|properties)$/.test(f) || f.endsWith('.gitignore'), + ); + + expect(textFiles.length).toBeGreaterThan(0); + + const filesWithMixedEndings = textFiles.filter((file) => { + const content = fs.readFileSync( + path.join(projectDir, file), + 'utf-8', + ); + const stripped = content.replace(/\r\n/g, ''); + return stripped.includes('\n'); + }); + + expect(filesWithMixedEndings).toEqual([]); + }); + }); }); diff --git a/src/android/android-wizard.ts b/src/android/android-wizard.ts index 79a429c18..462f95b7e 100644 --- a/src/android/android-wizard.ts +++ b/src/android/android-wizard.ts @@ -22,6 +22,7 @@ import * as codetools from './code-tools'; import * as gradle from './gradle'; import * as manifest from './manifest'; import { abortIfSpotlightNotSupported } from '../utils/abort-if-sportlight-not-supported'; +import { fixLineEndings } from '../utils/line-endings'; const proguardMappingCliSetupConfig: CliSetupConfig = { ...propertiesCliSetupConfig, @@ -187,6 +188,9 @@ async function runAndroidWizardWithTelemetry( selectedProject.slug, ); + // Fix mixed line endings caused by inserting LF content into CRLF files (Windows) + fixLineEndings(); + // ======== OUTRO ======== const issuesPageLink = selfHosted ? `${sentryUrl}organizations/${selectedProject.organization.slug}/issues/?project=${selectedProject.id}` diff --git a/src/flutter/flutter-wizard.ts b/src/flutter/flutter-wizard.ts index 1a59bd089..694c15d1e 100644 --- a/src/flutter/flutter-wizard.ts +++ b/src/flutter/flutter-wizard.ts @@ -20,6 +20,7 @@ import { traceStep, withTelemetry } from '../telemetry'; import { findFile } from './code-tools'; import { offerProjectScopedMcpConfig } from '../utils/clack/mcp-config'; import { abortIfSpotlightNotSupported } from '../utils/abort-if-sportlight-not-supported'; +import { fixLineEndings } from '../utils/line-endings'; export async function runFlutterWizard(options: WizardOptions): Promise { return withTelemetry( @@ -168,6 +169,9 @@ Set the ${chalk.cyan( selectedProject.slug, ); + // Fix mixed line endings caused by inserting LF content into CRLF files (Windows) + fixLineEndings(); + const issuesPageLink = selfHosted ? `${sentryUrl}organizations/${selectedProject.organization.slug}/issues/?project=${selectedProject.id}` : `https://${selectedProject.organization.slug}.sentry.io/issues/?project=${selectedProject.id}`; diff --git a/src/utils/line-endings.ts b/src/utils/line-endings.ts new file mode 100644 index 000000000..a406ac92b --- /dev/null +++ b/src/utils/line-endings.ts @@ -0,0 +1,56 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getUncommittedOrUntrackedFiles } from './git'; + +/** + * Fixes mixed line endings in files modified by the wizard. + * + * When the wizard reads a CRLF file and inserts content with hardcoded \n, + * the result is mixed line endings. This function detects files with CRLF + * and normalizes all line endings to CRLF. + * + * Call this at the end of a wizard run, similar to runPrettierIfInstalled(). + */ +const TEXT_EXTENSIONS = new Set([ + '.dart', + '.yaml', + '.yml', + '.properties', + '.java', + '.kt', + '.xml', + '.gradle', + '.kts', + '.gitignore', + '.json', +]); + +export function fixLineEndings(): void { + const files = getUncommittedOrUntrackedFiles() + .map((f) => (f.startsWith('- ') ? f.slice(2) : f)) + .filter(Boolean); + + for (const file of files) { + const filePath = path.resolve(file); + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + continue; + } + + const ext = path.extname(filePath).toLowerCase(); + const basename = path.basename(filePath); + if (!TEXT_EXTENSIONS.has(ext) && !TEXT_EXTENSIONS.has(basename)) { + continue; + } + + const content = fs.readFileSync(filePath, 'utf8'); + + if (!content.includes('\r\n')) { + continue; + } + + // File has CRLF — normalize all line endings to CRLF + const normalized = content.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); + fs.writeFileSync(filePath, normalized, 'utf8'); + } +} diff --git a/test/utils/line-endings.test.ts b/test/utils/line-endings.test.ts new file mode 100644 index 000000000..05de3ffc4 --- /dev/null +++ b/test/utils/line-endings.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { fixLineEndings } from '../../src/utils/line-endings'; +import * as git from '../../src/utils/git'; + +describe('fixLineEndings', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'line-endings-test-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true }); + vi.restoreAllMocks(); + }); + + it('normalizes mixed line endings to CRLF when file has CRLF', () => { + const filePath = path.join(tmpDir, 'mixed.dart'); + fs.writeFileSync(filePath, 'line1\r\nline2\nline3\r\n', 'utf8'); + + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([ + `- ${filePath}`, + ]); + + fixLineEndings(); + + const result = fs.readFileSync(filePath, 'utf8'); + expect(result).toBe('line1\r\nline2\r\nline3\r\n'); + }); + + it('skips files that are pure LF', () => { + const filePath = path.join(tmpDir, 'lf.dart'); + const original = 'line1\nline2\n'; + fs.writeFileSync(filePath, original, 'utf8'); + + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([ + `- ${filePath}`, + ]); + + fixLineEndings(); + + const result = fs.readFileSync(filePath, 'utf8'); + expect(result).toBe(original); + }); + + it('leaves consistent CRLF files unchanged', () => { + const filePath = path.join(tmpDir, 'crlf.dart'); + const original = 'line1\r\nline2\r\n'; + fs.writeFileSync(filePath, original, 'utf8'); + + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([ + `- ${filePath}`, + ]); + + fixLineEndings(); + + const result = fs.readFileSync(filePath, 'utf8'); + expect(result).toBe(original); + }); + + it('skips non-text files', () => { + const filePath = path.join(tmpDir, 'image.png'); + const original = 'line1\r\nline2\nline3\r\n'; + fs.writeFileSync(filePath, original, 'utf8'); + + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([ + `- ${filePath}`, + ]); + + fixLineEndings(); + + const result = fs.readFileSync(filePath, 'utf8'); + expect(result).toBe(original); + }); + + it('skips directories', () => { + const dirPath = path.join(tmpDir, 'subdir'); + fs.mkdirSync(dirPath); + + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([ + `- ${dirPath}`, + ]); + + expect(() => fixLineEndings()).not.toThrow(); + }); + + it('skips nonexistent files', () => { + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([ + '- nonexistent.txt', + ]); + + expect(() => fixLineEndings()).not.toThrow(); + }); + + it('does nothing when there are no modified files', () => { + vi.spyOn(git, 'getUncommittedOrUntrackedFiles').mockReturnValue([]); + + expect(() => fixLineEndings()).not.toThrow(); + }); +});