From 7da15d63a8fa425c2076dd9afb2e9f75df4d2be2 Mon Sep 17 00:00:00 2001 From: Blake Friedman Date: Fri, 3 Mar 2023 16:57:33 +0000 Subject: [PATCH] fix: doctor for 0.72 Ruby changes 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. --- packages/cli-doctor/src/commands/doctor.ts | 3 +- packages/cli-doctor/src/tools/envinfo.ts | 4 +- .../tools/healthchecks/__tests__/ruby.test.ts | 103 +++++++++++ .../src/tools/healthchecks/common.ts | 26 ++- .../cli-doctor/src/tools/healthchecks/ruby.ts | 169 +++++++++++++++++- .../cli-doctor/src/tools/versionRanges.ts | 2 +- packages/cli-doctor/src/types.ts | 3 +- 7 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 packages/cli-doctor/src/tools/healthchecks/__tests__/ruby.test.ts diff --git a/packages/cli-doctor/src/commands/doctor.ts b/packages/cli-doctor/src/commands/doctor.ts index 5f19c1743..e52477780 100644 --- a/packages/cli-doctor/src/commands/doctor.ts +++ b/packages/cli-doctor/src/commands/doctor.ts @@ -129,6 +129,7 @@ const doctorCommand = (async (_, options) => { } const { + description, needsToBeFixed, version, versions, @@ -145,7 +146,7 @@ const doctorCommand = (async (_, options) => { version, versions, versionRange, - description: healthcheck.description, + description: description ?? healthcheck.description, runAutomaticFix: getAutomaticFixForPlatform( healthcheck, process.platform, diff --git a/packages/cli-doctor/src/tools/envinfo.ts b/packages/cli-doctor/src/tools/envinfo.ts index 5caaf114e..e64b270da 100644 --- a/packages/cli-doctor/src/tools/envinfo.ts +++ b/packages/cli-doctor/src/tools/envinfo.ts @@ -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*'], diff --git a/packages/cli-doctor/src/tools/healthchecks/__tests__/ruby.test.ts b/packages/cli-doctor/src/tools/healthchecks/__tests__/ruby.test.ts new file mode 100644 index 000000000..41a0bc36c --- /dev/null +++ b/packages/cli-doctor/src/tools/healthchecks/__tests__/ruby.test.ts @@ -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', + }); + }); + }); +}); diff --git a/packages/cli-doctor/src/tools/healthchecks/common.ts b/packages/cli-doctor/src/tools/healthchecks/common.ts index af71f5044..15f34c653 100644 --- a/packages/cli-doctor/src/tools/healthchecks/common.ts +++ b/packages/cli-doctor/src/tools/healthchecks/common.ts @@ -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}; diff --git a/packages/cli-doctor/src/tools/healthchecks/ruby.ts b/packages/cli-doctor/src/tools/healthchecks/ruby.ts index 061259052..fd164941f 100644 --- a/packages/cli-doctor/src/tools/healthchecks/ruby.ts +++ b/packages/cli-doctor/src/tools/healthchecks/ruby.ts @@ -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 instead 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(); diff --git a/packages/cli-doctor/src/tools/versionRanges.ts b/packages/cli-doctor/src/tools/versionRanges.ts index 80eba6955..7bd261547 100644 --- a/packages/cli-doctor/src/tools/versionRanges.ts +++ b/packages/cli-doctor/src/tools/versionRanges.ts @@ -3,7 +3,7 @@ export default { NODE_JS: '>= 16', YARN: '>= 1.10.x', NPM: '>= 4.x', - RUBY: '>= 2.7.6', + RUBY: '>= 2.6.10', JAVA: '>= 11', // Android ANDROID_SDK: '>= 31.x', diff --git a/packages/cli-doctor/src/types.ts b/packages/cli-doctor/src/types.ts index e0e3ae656..e81676de6 100644 --- a/packages/cli-doctor/src/types.ts +++ b/packages/cli-doctor/src/types.ts @@ -26,7 +26,6 @@ export type EnvironmentInfo = { }; Managers: { CocoaPods: AvailableInformation; - RubyGems: AvailableInformation; }; SDKs: { 'iOS SDK': { @@ -51,6 +50,7 @@ export type EnvironmentInfo = { }; Languages: { Java: Information; + Ruby: AvailableInformation; }; }; @@ -89,6 +89,7 @@ export type HealthCheckInterface = { getDiagnostics: ( environmentInfo: EnvironmentInfo, ) => Promise<{ + description?: string; version?: string; versions?: [string]; versionRange?: string;