Skip to content

Commit

Permalink
fix: doctor for 0.72 Ruby changes
Browse files Browse the repository at this point in the history
The new release requires support for a range of Ruby version, which are
defined in the project Gemfile. This change add support for validating
against an in project Gemfile (or .ruby-version if it can't find one).
It finally falls back on the baked in version with the CLI.

There are also additional unit tests and some background comments.
  • Loading branch information
blakef committed Mar 6, 2023
1 parent 6302210 commit 3a7671c
Show file tree
Hide file tree
Showing 7 changed files with 297 additions and 13 deletions.
3 changes: 2 additions & 1 deletion packages/cli-doctor/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const doctorCommand = (async (_, options) => {
}

const {
description,
needsToBeFixed,
version,
versions,
Expand All @@ -145,7 +146,7 @@ const doctorCommand = (async (_, options) => {
version,
versions,
versionRange,
description: healthcheck.description,
description: description ?? healthcheck.description,
runAutomaticFix: getAutomaticFixForPlatform(
healthcheck,
process.platform,
Expand Down
4 changes: 2 additions & 2 deletions packages/cli-doctor/src/tools/envinfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ async function getEnvironmentInfo(
System: ['OS', 'CPU', 'Memory', 'Shell'],
Binaries: ['Node', 'Yarn', 'npm', 'Watchman'],
IDEs: ['Xcode', 'Android Studio', 'Visual Studio'],
Managers: ['CocoaPods', 'RubyGems'],
Languages: ['Java'],
Managers: ['CocoaPods'],
Languages: ['Java', 'Ruby'],
SDKs: ['iOS SDK', 'Android SDK', 'Windows SDK'],
npmPackages: packages,
npmGlobalPackages: ['*react-native*'],
Expand Down
103 changes: 103 additions & 0 deletions packages/cli-doctor/src/tools/healthchecks/__tests__/ruby.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import ruby, {output} from '../ruby';

//
// Mocks
//
const mockExeca = jest.fn();
jest.mock('execa', () => mockExeca);

const mockLogger = jest.fn();
jest.mock('@react-native-community/cli-tools', () => ({
findProjectRoot: () => '.',
logger: {
warn: mockLogger,
},
}));

jest.mock('../../versionRanges', () => ({
RUBY: '>= 1.0.0',
}));

//
// Placeholder Values
//
const Languages = {
Ruby: {version: '1.0.0'},
};

const runRubyGetDiagnostic = () => {
// @ts-ignore
return ruby.getDiagnostics({Languages});
};

const Gemfile = {
noGemfile: {code: 1},
noRuby: {code: 'ENOENT'},
ok: {stdout: output.OK},
unknown: (err: Error) => err,
wrongRuby: (stderr: string) => ({code: 2, stderr}),
};

//
// Tests
//

describe('ruby', () => {
beforeEach(() => {
mockLogger.mockClear();
mockExeca.mockClear();
});

describe('Gemfile', () => {
it('validates the environment', async () => {
mockExeca.mockResolvedValueOnce(Gemfile.ok);

expect(await runRubyGetDiagnostic()).toMatchObject({
needsToBeFixed: false,
});
});

it('fails to find ruby to run the script', async () => {
mockExeca.mockRejectedValueOnce(Gemfile.noRuby);

const resp = await runRubyGetDiagnostic();
expect(resp.needsToBeFixed).toEqual(true);
expect(resp.description).toMatch(/Ruby/i);
});

it('fails to find the Gemfile and messages the user', async () => {
mockExeca.mockRejectedValueOnce(Gemfile.noGemfile);

const {description} = await runRubyGetDiagnostic();
expect(description).toMatch(/could not find/i);
});

it('fails because the wrong version of ruby is installed', async () => {
const stderr = '>= 3.2.0, < 3.2.0';
mockExeca.mockRejectedValueOnce(Gemfile.wrongRuby(stderr));

expect(await runRubyGetDiagnostic()).toMatchObject({
needsToBeFixed: true,
versionRange: stderr,
});
});

it('fails for unknown reasons, so we skip it but log', async () => {
const error = Error('Something bad went wrong');
mockExeca.mockRejectedValueOnce(Gemfile.unknown(error));

await runRubyGetDiagnostic();
expect(mockLogger).toBeCalledTimes(1);
expect(mockLogger).toBeCalledWith(error.message);
});

it('uses are static ruby versions builtin into doctor if no Gemfile', async () => {
mockExeca.mockRejectedValueOnce(new Error('Meh'));
expect(await runRubyGetDiagnostic()).toMatchObject({
needsToBeFixed: false,
version: Languages.Ruby.version,
versionRange: '>= 1.0.0',
});
});
});
});
26 changes: 25 additions & 1 deletion packages/cli-doctor/src/tools/healthchecks/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,28 @@ function removeMessage(message: string) {
readline.clearScreenDown(process.stdout);
}

export {logMessage, logManualInstallation, logError, removeMessage};
/**
* Inline a series of Ruby statements:
*
* In:
* puts "a"
* puts "b"
*
* Out:
* puts "a"; puts "b";
*/
function inline(
strings: TemplateStringsArray,
...values: {toString(): string}[]
) {
const zipped = strings.map((str, i) => `${str}${values[i] ?? ''}`).join('');

return zipped
.trim()
.split('\n')
.filter((line) => !/^\W*$/.test(line))
.map((line) => line.trim())
.join('; ');
}

export {logMessage, logManualInstallation, logError, removeMessage, inline};
169 changes: 162 additions & 7 deletions packages/cli-doctor/src/tools/healthchecks/ruby.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,174 @@
import execa from 'execa';
import chalk from 'chalk';

import {logger, findProjectRoot} from '@react-native-community/cli-tools';

import versionRanges from '../versionRanges';
import {doesSoftwareNeedToBeFixed} from '../checkInstallation';
import {HealthCheckInterface} from '../../types';
import {inline} from './common';

// Exposed for testing only
export const output = {
OK: 'Ok',
NO_GEMFILE: 'No Gemfile',
NO_RUBY: 'No Ruby',
BUNDLE_INVALID_RUBY: 'Bundle invalid Ruby',
UNKNOWN: 'Unknown',
} as const;

// The Change:
// -----------
//
// React Native 0.72 primarily defines the compatible version of Ruby in the
// project's Gemfile [1]. It does this because it allows for ranges insteado of
// pinning to a version of Ruby.
//
// In previous versions the .ruby-version file defined the compatible version,
// and it was derived in the Gemfile [2]:
//
// > ruby File.read(File.join(__dir__, '.ruby-version')).strip
//
// Why all of the changes with Ruby?
// ---------------------------------
//
// React Native has had to weigh up a couple of concerns:
//
// - Cocoapods: we don't control the minimum supported version, although that
// was defined almost a decade ago [3]. Practically system Ruby on macOS works
// for our users.
//
// - Apple may drop support for scripting language runtimes in future version of
// macOS [4]. Ruby 2.7 is effectively EOL, which means many supporting tools and
// developer environments _may_ not support it going forward, and 3.0 is becoming
// the default in, for example, places like our CI. Some users may be unable to
// install Ruby 2.7 on their devices as a matter of policy.
//
// - Our Codegen is extensively built in Ruby 2.7.
//
// - A common pain-point for users (old and new) setting up their environment is
// configuring a Ruby version manager or managing multiple Ruby versions on their
// device. This occurs so frequently that we've removed the step from our docs [6]
//
// After users suggested bumping Ruby to 3.1.3 [5], a discussion concluded that
// allowing a range of version of Ruby (>= 2.6.10) was the best way forward. This
// balanced the need to make the platform easier to start with, but unblocked more
// sophisticated users.
//
// [1] https://github.com/facebook/react-native/pull/36281
// [2] https://github.com/facebook/react-native/blob/v0.71.3/Gemfile#L4
// [3] https://github.com/CocoaPods/guides.cocoapods.org/commit/30881800ac2bd431d9c5d7ee74404b13e7f43888
// [4] https://developer.apple.com/documentation/macos-release-notes/macos-catalina-10_15-release-notes#Scripting-Language-Runtimes
// [5] https://github.com/facebook/react-native/pull/36074
// [6] https://github.com/facebook/react-native-website/commit/8db97602347a8623f21e3e516245d04bdf6f1a29

async function checkRubyGemfileRequirement(
projectRoot: string,
): Promise<[string, string?]> {
const evaluateGemfile = inline`
require "Bundler"
gemfile = Bundler::Definition.build("Gemfile", nil, {})
version = gemfile.ruby_version.engine_versions.join(", ")
begin
gemfile.validate_runtime!
rescue Bundler::GemfileNotFound
puts "${output.NO_GEMFILE}"
exit 1
rescue Bundler::RubyVersionMismatch
puts "${output.BUNDLE_INVALID_RUBY}"
STDERR.puts version
exit 2
rescue => e
STDERR e.message
exit 3
else
puts "${output.OK}"
STDERR.puts version
end`;

try {
await execa('ruby', ['-e', evaluateGemfile], {
cwd: projectRoot,
});
return [output.OK];
} catch (e) {
switch (e.code) {
case 'ENOENT':
return [output.NO_RUBY];
case 1:
return [output.NO_GEMFILE];
case 2:
return [output.BUNDLE_INVALID_RUBY, e.stderr];
default:
return [output.UNKNOWN, e.message];
}
}
}

export default {
label: 'Ruby',
isRequired: false,
description: 'Required for installing iOS dependencies',
getDiagnostics: async ({Managers}) => ({
needsToBeFixed: doesSoftwareNeedToBeFixed({
version: Managers.RubyGems.version,
getDiagnostics: async ({Languages}) => {
let projectRoot;
try {
projectRoot = findProjectRoot();
} catch (e) {
logger.debug(e.message);
}

const fallbackResult = {
needsToBeFixed: doesSoftwareNeedToBeFixed({
version: Languages.Ruby.version,
versionRange: versionRanges.RUBY,
}),
version: Languages.Ruby.version,
versionRange: versionRanges.RUBY,
}),
version: Managers.RubyGems.version,
versionRange: versionRanges.RUBY,
}),
description: '',
};

// No guidance from the project, so we make the best guess
if (!projectRoot) {
return fallbackResult;
}

// Gemfile
let [code, versionOrError] = await checkRubyGemfileRequirement(projectRoot);
switch (code) {
case output.OK: {
return {
needsToBeFixed: false,
version: Languages.Ruby.version,
versionRange: versionOrError,
};
}
case output.BUNDLE_INVALID_RUBY:
return {
needsToBeFixed: true,
version: Languages.Ruby.version,
versionRange: versionOrError,
};
case output.NO_RUBY:
return {
needsToBeFixed: true,
description: 'Cannot find a working copy of Ruby.',
};
case output.NO_GEMFILE:
fallbackResult.description = `Could not find the project ${chalk.bold(
'Gemfile',
)} in your project folder (${chalk.dim(
projectRoot,
)}), guessed using my built-in version.`;
break;
default:
if (versionOrError) {
logger.warn(versionOrError);
}
break;
}

return fallbackResult;
},
runAutomaticFix: async ({loader, logManualInstallation}) => {
loader.fail();

Expand Down
2 changes: 1 addition & 1 deletion packages/cli-doctor/src/tools/versionRanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export default {
NODE_JS: '>= 16',
YARN: '>= 1.10.x',
NPM: '>= 4.x',
RUBY: '>= 2.7.6',
RUBY: '>= 2.7.10',
JAVA: '>= 11',
// Android
ANDROID_SDK: '>= 31.x',
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-doctor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export type EnvironmentInfo = {
};
Managers: {
CocoaPods: AvailableInformation;
RubyGems: AvailableInformation;
};
SDKs: {
'iOS SDK': {
Expand All @@ -51,6 +50,7 @@ export type EnvironmentInfo = {
};
Languages: {
Java: Information;
Ruby: AvailableInformation;
};
};

Expand Down Expand Up @@ -89,6 +89,7 @@ export type HealthCheckInterface = {
getDiagnostics: (
environmentInfo: EnvironmentInfo,
) => Promise<{
description?: string;
version?: string;
versions?: [string];
versionRange?: string;
Expand Down

0 comments on commit 3a7671c

Please sign in to comment.