Skip to content

Commit 7da15d6

Browse files
committed
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.
1 parent 6302210 commit 7da15d6

File tree

7 files changed

+297
-13
lines changed

7 files changed

+297
-13
lines changed

packages/cli-doctor/src/commands/doctor.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const doctorCommand = (async (_, options) => {
129129
}
130130

131131
const {
132+
description,
132133
needsToBeFixed,
133134
version,
134135
versions,
@@ -145,7 +146,7 @@ const doctorCommand = (async (_, options) => {
145146
version,
146147
versions,
147148
versionRange,
148-
description: healthcheck.description,
149+
description: description ?? healthcheck.description,
149150
runAutomaticFix: getAutomaticFixForPlatform(
150151
healthcheck,
151152
process.platform,

packages/cli-doctor/src/tools/envinfo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ async function getEnvironmentInfo(
3333
System: ['OS', 'CPU', 'Memory', 'Shell'],
3434
Binaries: ['Node', 'Yarn', 'npm', 'Watchman'],
3535
IDEs: ['Xcode', 'Android Studio', 'Visual Studio'],
36-
Managers: ['CocoaPods', 'RubyGems'],
37-
Languages: ['Java'],
36+
Managers: ['CocoaPods'],
37+
Languages: ['Java', 'Ruby'],
3838
SDKs: ['iOS SDK', 'Android SDK', 'Windows SDK'],
3939
npmPackages: packages,
4040
npmGlobalPackages: ['*react-native*'],
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import ruby, {output} from '../ruby';
2+
3+
//
4+
// Mocks
5+
//
6+
const mockExeca = jest.fn();
7+
jest.mock('execa', () => mockExeca);
8+
9+
const mockLogger = jest.fn();
10+
jest.mock('@react-native-community/cli-tools', () => ({
11+
findProjectRoot: () => '.',
12+
logger: {
13+
warn: mockLogger,
14+
},
15+
}));
16+
17+
jest.mock('../../versionRanges', () => ({
18+
RUBY: '>= 1.0.0',
19+
}));
20+
21+
//
22+
// Placeholder Values
23+
//
24+
const Languages = {
25+
Ruby: {version: '1.0.0'},
26+
};
27+
28+
const runRubyGetDiagnostic = () => {
29+
// @ts-ignore
30+
return ruby.getDiagnostics({Languages});
31+
};
32+
33+
const Gemfile = {
34+
noGemfile: {code: 1},
35+
noRuby: {code: 'ENOENT'},
36+
ok: {stdout: output.OK},
37+
unknown: (err: Error) => err,
38+
wrongRuby: (stderr: string) => ({code: 2, stderr}),
39+
};
40+
41+
//
42+
// Tests
43+
//
44+
45+
describe('ruby', () => {
46+
beforeEach(() => {
47+
mockLogger.mockClear();
48+
mockExeca.mockClear();
49+
});
50+
51+
describe('Gemfile', () => {
52+
it('validates the environment', async () => {
53+
mockExeca.mockResolvedValueOnce(Gemfile.ok);
54+
55+
expect(await runRubyGetDiagnostic()).toMatchObject({
56+
needsToBeFixed: false,
57+
});
58+
});
59+
60+
it('fails to find ruby to run the script', async () => {
61+
mockExeca.mockRejectedValueOnce(Gemfile.noRuby);
62+
63+
const resp = await runRubyGetDiagnostic();
64+
expect(resp.needsToBeFixed).toEqual(true);
65+
expect(resp.description).toMatch(/Ruby/i);
66+
});
67+
68+
it('fails to find the Gemfile and messages the user', async () => {
69+
mockExeca.mockRejectedValueOnce(Gemfile.noGemfile);
70+
71+
const {description} = await runRubyGetDiagnostic();
72+
expect(description).toMatch(/could not find/i);
73+
});
74+
75+
it('fails because the wrong version of ruby is installed', async () => {
76+
const stderr = '>= 3.2.0, < 3.2.0';
77+
mockExeca.mockRejectedValueOnce(Gemfile.wrongRuby(stderr));
78+
79+
expect(await runRubyGetDiagnostic()).toMatchObject({
80+
needsToBeFixed: true,
81+
versionRange: stderr,
82+
});
83+
});
84+
85+
it('fails for unknown reasons, so we skip it but log', async () => {
86+
const error = Error('Something bad went wrong');
87+
mockExeca.mockRejectedValueOnce(Gemfile.unknown(error));
88+
89+
await runRubyGetDiagnostic();
90+
expect(mockLogger).toBeCalledTimes(1);
91+
expect(mockLogger).toBeCalledWith(error.message);
92+
});
93+
94+
it('uses are static ruby versions builtin into doctor if no Gemfile', async () => {
95+
mockExeca.mockRejectedValueOnce(new Error('Meh'));
96+
expect(await runRubyGetDiagnostic()).toMatchObject({
97+
needsToBeFixed: false,
98+
version: Languages.Ruby.version,
99+
versionRange: '>= 1.0.0',
100+
});
101+
});
102+
});
103+
});

packages/cli-doctor/src/tools/healthchecks/common.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,28 @@ function removeMessage(message: string) {
104104
readline.clearScreenDown(process.stdout);
105105
}
106106

107-
export {logMessage, logManualInstallation, logError, removeMessage};
107+
/**
108+
* Inline a series of Ruby statements:
109+
*
110+
* In:
111+
* puts "a"
112+
* puts "b"
113+
*
114+
* Out:
115+
* puts "a"; puts "b";
116+
*/
117+
function inline(
118+
strings: TemplateStringsArray,
119+
...values: {toString(): string}[]
120+
) {
121+
const zipped = strings.map((str, i) => `${str}${values[i] ?? ''}`).join('');
122+
123+
return zipped
124+
.trim()
125+
.split('\n')
126+
.filter((line) => !/^\W*$/.test(line))
127+
.map((line) => line.trim())
128+
.join('; ');
129+
}
130+
131+
export {logMessage, logManualInstallation, logError, removeMessage, inline};

packages/cli-doctor/src/tools/healthchecks/ruby.ts

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,174 @@
1+
import execa from 'execa';
2+
import chalk from 'chalk';
3+
4+
import {logger, findProjectRoot} from '@react-native-community/cli-tools';
5+
16
import versionRanges from '../versionRanges';
27
import {doesSoftwareNeedToBeFixed} from '../checkInstallation';
38
import {HealthCheckInterface} from '../../types';
9+
import {inline} from './common';
10+
11+
// Exposed for testing only
12+
export const output = {
13+
OK: 'Ok',
14+
NO_GEMFILE: 'No Gemfile',
15+
NO_RUBY: 'No Ruby',
16+
BUNDLE_INVALID_RUBY: 'Bundle invalid Ruby',
17+
UNKNOWN: 'Unknown',
18+
} as const;
19+
20+
// The Change:
21+
// -----------
22+
//
23+
// React Native 0.72 primarily defines the compatible version of Ruby in the
24+
// project's Gemfile [1]. It does this because it allows for ranges instead of
25+
// pinning to a version of Ruby.
26+
//
27+
// In previous versions the .ruby-version file defined the compatible version,
28+
// and it was derived in the Gemfile [2]:
29+
//
30+
// > ruby File.read(File.join(__dir__, '.ruby-version')).strip
31+
//
32+
// Why all of the changes with Ruby?
33+
// ---------------------------------
34+
//
35+
// React Native has had to weigh up a couple of concerns:
36+
//
37+
// - Cocoapods: we don't control the minimum supported version, although that
38+
// was defined almost a decade ago [3]. Practically system Ruby on macOS works
39+
// for our users.
40+
//
41+
// - Apple may drop support for scripting language runtimes in future version of
42+
// macOS [4]. Ruby 2.7 is effectively EOL, which means many supporting tools and
43+
// developer environments _may_ not support it going forward, and 3.0 is becoming
44+
// the default in, for example, places like our CI. Some users may be unable to
45+
// install Ruby 2.7 on their devices as a matter of policy.
46+
//
47+
// - Our Codegen is extensively built in Ruby 2.7.
48+
//
49+
// - A common pain-point for users (old and new) setting up their environment is
50+
// configuring a Ruby version manager or managing multiple Ruby versions on their
51+
// device. This occurs so frequently that we've removed the step from our docs [6]
52+
//
53+
// After users suggested bumping Ruby to 3.1.3 [5], a discussion concluded that
54+
// allowing a range of version of Ruby (>= 2.6.10) was the best way forward. This
55+
// balanced the need to make the platform easier to start with, but unblocked more
56+
// sophisticated users.
57+
//
58+
// [1] https://github.com/facebook/react-native/pull/36281
59+
// [2] https://github.com/facebook/react-native/blob/v0.71.3/Gemfile#L4
60+
// [3] https://github.com/CocoaPods/guides.cocoapods.org/commit/30881800ac2bd431d9c5d7ee74404b13e7f43888
61+
// [4] https://developer.apple.com/documentation/macos-release-notes/macos-catalina-10_15-release-notes#Scripting-Language-Runtimes
62+
// [5] https://github.com/facebook/react-native/pull/36074
63+
// [6] https://github.com/facebook/react-native-website/commit/8db97602347a8623f21e3e516245d04bdf6f1a29
64+
65+
async function checkRubyGemfileRequirement(
66+
projectRoot: string,
67+
): Promise<[string, string?]> {
68+
const evaluateGemfile = inline`
69+
require "Bundler"
70+
gemfile = Bundler::Definition.build("Gemfile", nil, {})
71+
version = gemfile.ruby_version.engine_versions.join(", ")
72+
begin
73+
gemfile.validate_runtime!
74+
rescue Bundler::GemfileNotFound
75+
puts "${output.NO_GEMFILE}"
76+
exit 1
77+
rescue Bundler::RubyVersionMismatch
78+
puts "${output.BUNDLE_INVALID_RUBY}"
79+
STDERR.puts version
80+
exit 2
81+
rescue => e
82+
STDERR e.message
83+
exit 3
84+
else
85+
puts "${output.OK}"
86+
STDERR.puts version
87+
end`;
88+
89+
try {
90+
await execa('ruby', ['-e', evaluateGemfile], {
91+
cwd: projectRoot,
92+
});
93+
return [output.OK];
94+
} catch (e) {
95+
switch (e.code) {
96+
case 'ENOENT':
97+
return [output.NO_RUBY];
98+
case 1:
99+
return [output.NO_GEMFILE];
100+
case 2:
101+
return [output.BUNDLE_INVALID_RUBY, e.stderr];
102+
default:
103+
return [output.UNKNOWN, e.message];
104+
}
105+
}
106+
}
4107

5108
export default {
6109
label: 'Ruby',
7110
isRequired: false,
8111
description: 'Required for installing iOS dependencies',
9-
getDiagnostics: async ({Managers}) => ({
10-
needsToBeFixed: doesSoftwareNeedToBeFixed({
11-
version: Managers.RubyGems.version,
112+
getDiagnostics: async ({Languages}) => {
113+
let projectRoot;
114+
try {
115+
projectRoot = findProjectRoot();
116+
} catch (e) {
117+
logger.debug(e.message);
118+
}
119+
120+
const fallbackResult = {
121+
needsToBeFixed: doesSoftwareNeedToBeFixed({
122+
version: Languages.Ruby.version,
123+
versionRange: versionRanges.RUBY,
124+
}),
125+
version: Languages.Ruby.version,
12126
versionRange: versionRanges.RUBY,
13-
}),
14-
version: Managers.RubyGems.version,
15-
versionRange: versionRanges.RUBY,
16-
}),
127+
description: '',
128+
};
129+
130+
// No guidance from the project, so we make the best guess
131+
if (!projectRoot) {
132+
return fallbackResult;
133+
}
134+
135+
// Gemfile
136+
let [code, versionOrError] = await checkRubyGemfileRequirement(projectRoot);
137+
switch (code) {
138+
case output.OK: {
139+
return {
140+
needsToBeFixed: false,
141+
version: Languages.Ruby.version,
142+
versionRange: versionOrError,
143+
};
144+
}
145+
case output.BUNDLE_INVALID_RUBY:
146+
return {
147+
needsToBeFixed: true,
148+
version: Languages.Ruby.version,
149+
versionRange: versionOrError,
150+
};
151+
case output.NO_RUBY:
152+
return {
153+
needsToBeFixed: true,
154+
description: 'Cannot find a working copy of Ruby.',
155+
};
156+
case output.NO_GEMFILE:
157+
fallbackResult.description = `Could not find the project ${chalk.bold(
158+
'Gemfile',
159+
)} in your project folder (${chalk.dim(
160+
projectRoot,
161+
)}), guessed using my built-in version.`;
162+
break;
163+
default:
164+
if (versionOrError) {
165+
logger.warn(versionOrError);
166+
}
167+
break;
168+
}
169+
170+
return fallbackResult;
171+
},
17172
runAutomaticFix: async ({loader, logManualInstallation}) => {
18173
loader.fail();
19174

packages/cli-doctor/src/tools/versionRanges.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export default {
33
NODE_JS: '>= 16',
44
YARN: '>= 1.10.x',
55
NPM: '>= 4.x',
6-
RUBY: '>= 2.7.6',
6+
RUBY: '>= 2.6.10',
77
JAVA: '>= 11',
88
// Android
99
ANDROID_SDK: '>= 31.x',

packages/cli-doctor/src/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ export type EnvironmentInfo = {
2626
};
2727
Managers: {
2828
CocoaPods: AvailableInformation;
29-
RubyGems: AvailableInformation;
3029
};
3130
SDKs: {
3231
'iOS SDK': {
@@ -51,6 +50,7 @@ export type EnvironmentInfo = {
5150
};
5251
Languages: {
5352
Java: Information;
53+
Ruby: AvailableInformation;
5454
};
5555
};
5656

@@ -89,6 +89,7 @@ export type HealthCheckInterface = {
8989
getDiagnostics: (
9090
environmentInfo: EnvironmentInfo,
9191
) => Promise<{
92+
description?: string;
9293
version?: string;
9394
versions?: [string];
9495
versionRange?: string;

0 commit comments

Comments
 (0)