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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions e2e-tests/tests/flutter.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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([]);
});
});
});
4 changes: 4 additions & 0 deletions src/android/android-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}`
Expand Down
4 changes: 4 additions & 0 deletions src/flutter/flutter-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return withTelemetry(
Expand Down Expand Up @@ -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}`;
Expand Down
56 changes: 56 additions & 0 deletions src/utils/line-endings.ts
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The new fixLineEndings function relies on a utility that incorrectly parses file paths with spaces, causing those files to be silently skipped during line ending normalization.
Severity: MEDIUM

Suggested Fix

Modify getUncommittedOrUntrackedFiles to correctly parse filenames with spaces. The recommended approach is to use git status --porcelain -z, which provides NUL-terminated output, making parsing unambiguous and robust.

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

Location: src/utils/line-endings.ts#L29

Potential issue: The `getUncommittedOrUntrackedFiles` function parses `git status`
output by splitting on whitespace. This causes it to incorrectly handle filenames that
contain spaces, truncating the path. The new `fixLineEndings` function calls this
pre-existing utility. As a result, any uncommitted files with spaces in their names will
be silently skipped during the line-ending normalization process, leading to an
incomplete fix for users on Windows environments where this is common.

.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');
}
}
103 changes: 103 additions & 0 deletions test/utils/line-endings.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading