Do not show Doctor warning on startup for already seen problems

Summary: Doctor sometimes can show false-positives and in such case there is no way to suppress showing warning message on each startup. To reduce annoyance I've added an option to save the problems already seen by user and show the Doctor warning only for new problems.

Reviewed By: mweststrate

Differential Revision: D19187095

fbshipit-source-id: 14c1fcc9674f47fbe0b5b0f2d5d1bceb47f7b45d
This commit is contained in:
Anton Nikolaev
2020-01-02 08:53:45 -08:00
committed by Facebook Github Bot
parent 74daa3fb1a
commit 2bee021966
11 changed files with 635 additions and 405 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "flipper-doctor", "name": "flipper-doctor",
"version": "0.2.4", "version": "0.4.1",
"description": "Utility for checking for issues with a flipper installation", "description": "Utility for checking for issues with a flipper installation",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",

View File

@@ -17,8 +17,9 @@ import {getEnvInfo} from './environmentInfo';
const results = await Promise.all( const results = await Promise.all(
Object.entries(healthchecks).map(async ([key, category]) => [ Object.entries(healthchecks).map(async ([key, category]) => [
key, key,
category category.isSkipped
? { ? category
: {
label: category.label, label: category.label,
results: await Promise.all( results: await Promise.all(
category.healthchecks.map(async ({label, run}) => ({ category.healthchecks.map(async ({label, run}) => ({
@@ -26,8 +27,7 @@ import {getEnvInfo} from './environmentInfo';
result: await run(environmentInfo), result: await run(environmentInfo),
})), })),
), ),
} },
: {},
]), ]),
); );

View File

@@ -12,19 +12,26 @@ import {promisify} from 'util';
import {EnvironmentInfo, getEnvInfo} from './environmentInfo'; import {EnvironmentInfo, getEnvInfo} from './environmentInfo';
export {getEnvInfo} from './environmentInfo'; export {getEnvInfo} from './environmentInfo';
type HealthcheckCategory = { export type HealthcheckCategory = {
label: string; label: string;
isSkipped: false;
isRequired: boolean; isRequired: boolean;
healthchecks: Healthcheck[]; healthchecks: Healthcheck[];
}; };
type Healthchecks = { export type SkippedHealthcheckCategory = {
common: HealthcheckCategory; label: string;
android: HealthcheckCategory; isSkipped: true;
ios?: HealthcheckCategory; skipReason: string;
}; };
type Healthcheck = { export type Healthchecks = {
common: HealthcheckCategory | SkippedHealthcheckCategory;
android: HealthcheckCategory | SkippedHealthcheckCategory;
ios: HealthcheckCategory | SkippedHealthcheckCategory;
};
export type Healthcheck = {
label: string; label: string;
isRequired?: boolean; isRequired?: boolean;
run: ( run: (
@@ -35,7 +42,7 @@ type Healthcheck = {
}>; }>;
}; };
type CategoryResult = [ export type CategoryResult = [
string, string,
{ {
label: string; label: string;
@@ -52,6 +59,7 @@ export function getHealthchecks(): Healthchecks {
common: { common: {
label: 'Common', label: 'Common',
isRequired: true, isRequired: true,
isSkipped: false,
healthchecks: [ healthchecks: [
{ {
label: 'OpenSSL Installed', label: 'OpenSSL Installed',
@@ -67,6 +75,7 @@ export function getHealthchecks(): Healthchecks {
android: { android: {
label: 'Android', label: 'Android',
isRequired: false, isRequired: false,
isSkipped: false,
healthchecks: [ healthchecks: [
{ {
label: 'SDK Installed', label: 'SDK Installed',
@@ -77,11 +86,12 @@ export function getHealthchecks(): Healthchecks {
}, },
], ],
}, },
...(process.platform === 'darwin'
? {
ios: { ios: {
label: 'iOS', label: 'iOS',
...(process.platform === 'darwin'
? {
isRequired: false, isRequired: false,
isSkipped: false,
healthchecks: [ healthchecks: [
{ {
label: 'SDK Installed', label: 'SDK Installed',
@@ -118,20 +128,26 @@ export function getHealthchecks(): Healthchecks {
}, },
}, },
], ],
},
} }
: {}), : {
isSkipped: true,
skipReason: `Healthcheck is skipped, because iOS development is not supported on the current platform "${process.platform}"`,
}),
},
}; };
} }
export async function runHealthchecks(): Promise<Array<CategoryResult>> { export async function runHealthchecks(): Promise<
Array<CategoryResult | SkippedHealthcheckCategory>
> {
const environmentInfo = await getEnvInfo(); const environmentInfo = await getEnvInfo();
const healthchecks: Healthchecks = getHealthchecks(); const healthchecks: Healthchecks = getHealthchecks();
const results: Array<CategoryResult> = ( const results: Array<
await Promise.all( CategoryResult | SkippedHealthcheckCategory
> = await Promise.all(
Object.entries(healthchecks).map(async ([key, category]) => { Object.entries(healthchecks).map(async ([key, category]) => {
if (!category) { if (category.isSkipped) {
return null; return category;
} }
const categoryResult: CategoryResult = [ const categoryResult: CategoryResult = [
key, key,
@@ -154,8 +170,7 @@ export async function runHealthchecks(): Promise<Array<CategoryResult>> {
]; ];
return categoryResult; return categoryResult;
}), }),
) );
).filter(notNull);
return results; return results;
} }
@@ -164,7 +179,3 @@ async function commandSucceeds(command: string): Promise<boolean> {
.then(() => true) .then(() => true)
.catch(() => false); .catch(() => false);
} }
export function notNull<T>(x: T | null | undefined): x is T {
return x !== null && x !== undefined;
}

View File

@@ -138,7 +138,7 @@
"emotion": "^10.0.23", "emotion": "^10.0.23",
"expand-tilde": "^2.0.2", "expand-tilde": "^2.0.2",
"express": "^4.15.2", "express": "^4.15.2",
"flipper-doctor": "^0.2.4", "flipper-doctor": "^0.4.1",
"fs-extra": "^8.0.1", "fs-extra": "^8.0.1",
"immer": "^5.0.1", "immer": "^5.0.1",
"immutable": "^4.0.0-rc.12", "immutable": "^4.0.0-rc.12",

View File

@@ -23,22 +23,21 @@ import runHealthchecks, {
HealthcheckEventsHandler, HealthcheckEventsHandler,
} from '../utils/runHealthchecks'; } from '../utils/runHealthchecks';
import { import {
initHealthcheckReport, updateHealthcheckResult,
updateHealthcheckReportItemStatus,
updateHealthcheckReportCategoryStatus,
startHealthchecks, startHealthchecks,
finishHealthchecks, finishHealthchecks,
HealthcheckStatus,
} from '../reducers/healthchecks'; } from '../reducers/healthchecks';
type StateFromProps = HealthcheckSettings; type StateFromProps = {
healthcheckStatus: HealthcheckStatus;
} & HealthcheckSettings;
type DispatchFromProps = { type DispatchFromProps = {
setActiveSheet: (payload: ActiveSheet) => void; setActiveSheet: (payload: ActiveSheet) => void;
} & HealthcheckEventsHandler; } & HealthcheckEventsHandler;
type State = { type State = {visible: boolean};
visible: boolean;
};
type Props = DispatchFromProps & StateFromProps; type Props = DispatchFromProps & StateFromProps;
class DoctorBar extends Component<Props, State> { class DoctorBar extends Component<Props, State> {
@@ -52,8 +51,8 @@ class DoctorBar extends Component<Props, State> {
this.showMessageIfChecksFailed(); this.showMessageIfChecksFailed();
} }
async showMessageIfChecksFailed() { async showMessageIfChecksFailed() {
const result = await runHealthchecks(this.props); await runHealthchecks(this.props);
if (!result) { if (this.props.healthcheckStatus === 'FAILED') {
this.setVisible(true); this.setVisible(true);
} }
} }
@@ -66,9 +65,10 @@ class DoctorBar extends Component<Props, State> {
<ButtonSection> <ButtonSection>
<ButtonGroup> <ButtonGroup>
<Button <Button
onClick={() => onClick={() => {
this.props.setActiveSheet(ACTIVE_SHEET_DOCTOR) this.props.setActiveSheet(ACTIVE_SHEET_DOCTOR);
}> this.setVisible(false);
}}>
Show Problems Show Problems
</Button> </Button>
<Button onClick={() => this.setVisible(false)}> <Button onClick={() => this.setVisible(false)}>
@@ -96,14 +96,18 @@ class DoctorBar extends Component<Props, State> {
} }
export default connect<StateFromProps, DispatchFromProps, {}, Store>( export default connect<StateFromProps, DispatchFromProps, {}, Store>(
({settingsState}) => ({ ({
enableAndroid: settingsState.enableAndroid, settingsState: {enableAndroid},
healthchecks: {
healthcheckReport: {status},
},
}) => ({
enableAndroid,
healthcheckStatus: status,
}), }),
{ {
setActiveSheet, setActiveSheet,
initHealthcheckReport, updateHealthcheckResult,
updateHealthcheckReportItemStatus,
updateHealthcheckReportCategoryStatus,
startHealthchecks, startHealthchecks,
finishHealthchecks, finishHealthchecks,
}, },

View File

@@ -18,6 +18,7 @@ import {
Spacer, Spacer,
Button, Button,
FlexBox, FlexBox,
Checkbox,
} from 'flipper'; } from 'flipper';
import React, {Component} from 'react'; import React, {Component} from 'react';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
@@ -27,11 +28,12 @@ import {
HealthcheckReportCategory, HealthcheckReportCategory,
HealthcheckReportItem, HealthcheckReportItem,
HealthcheckReport, HealthcheckReport,
initHealthcheckReport,
updateHealthcheckReportItemStatus,
updateHealthcheckReportCategoryStatus,
startHealthchecks, startHealthchecks,
finishHealthchecks, finishHealthchecks,
updateHealthcheckResult,
acknowledgeProblems,
resetAcknowledgedProblems,
HealthcheckStatus,
} from '../reducers/healthchecks'; } from '../reducers/healthchecks';
import runHealthchecks, { import runHealthchecks, {
HealthcheckSettings, HealthcheckSettings,
@@ -40,10 +42,13 @@ import runHealthchecks, {
import {shell} from 'electron'; import {shell} from 'electron';
type StateFromProps = { type StateFromProps = {
report: HealthcheckReport; healthcheckReport: HealthcheckReport;
} & HealthcheckSettings; } & HealthcheckSettings;
type DispatchFromProps = HealthcheckEventsHandler; type DispatchFromProps = {
acknowledgeProblems: () => void;
resetAcknowledgedProblems: () => void;
} & HealthcheckEventsHandler;
const Container = styled(FlexColumn)({ const Container = styled(FlexColumn)({
padding: 20, padding: 20,
@@ -57,6 +62,7 @@ const HealthcheckDisplayContainer = styled(FlexRow)({
const HealthcheckListContainer = styled(FlexColumn)({ const HealthcheckListContainer = styled(FlexColumn)({
marginBottom: 20, marginBottom: 20,
width: 300,
}); });
const Title = styled(Text)({ const Title = styled(Text)({
@@ -77,7 +83,7 @@ const SideContainer = styled(FlexBox)({
padding: 20, padding: 20,
backgroundColor: colors.highlightBackground, backgroundColor: colors.highlightBackground,
border: '1px solid #b3b3b3', border: '1px solid #b3b3b3',
width: 320, width: 250,
}); });
const SideContainerText = styled(Text)({ const SideContainerText = styled(Text)({
@@ -89,10 +95,34 @@ const HealthcheckLabel = styled(Text)({
paddingLeft: 5, paddingLeft: 5,
}); });
const SkipReasonLabel = styled(Text)({
paddingLeft: 21,
fontStyle: 'italic',
});
const CenteredContainer = styled.label({
display: 'flex',
alignItems: 'center',
});
type OwnProps = { type OwnProps = {
onHide: () => void; onHide: () => void;
}; };
function CenteredCheckbox(props: {
checked: boolean;
text: string;
onChange: (checked: boolean) => void;
}) {
const {checked, onChange, text} = props;
return (
<CenteredContainer>
<Checkbox checked={checked} onChange={onChange} />
{text}
</CenteredContainer>
);
}
function HealthcheckIcon(props: {check: HealthcheckResult}) { function HealthcheckIcon(props: {check: HealthcheckResult}) {
switch (props.check.status) { switch (props.check.status) {
case 'IN_PROGRESS': case 'IN_PROGRESS':
@@ -115,21 +145,31 @@ function HealthcheckIcon(props: {check: HealthcheckResult}) {
title={props.check.message} title={props.check.message}
/> />
); );
case 'WARNING': case 'FAILED':
return ( return (
<Glyph <Glyph
size={16} size={16}
name={'caution'} name={'cross'}
color={colors.yellow} color={colors.red}
title={props.check.message} title={props.check.message}
/> />
); );
case 'FAILED_ACKNOWLEDGED':
return (
<Glyph
size={16}
name={'cross'}
color={colors.red}
title={props.check.message}
variant="outline"
/>
);
default: default:
return ( return (
<Glyph <Glyph
size={16} size={16}
name={'cross'} name={'caution'}
color={colors.red} color={colors.yellow}
title={props.check.message} title={props.check.message}
/> />
); );
@@ -181,13 +221,79 @@ function SideMessageDisplay(props: {
} }
} }
export type State = {}; function hasProblems(status: HealthcheckStatus) {
return (
status === 'FAILED' ||
status === 'FAILED_ACKNOWLEDGED' ||
status === 'WARNING'
);
}
function hasFailedChecks(status: HealthcheckStatus) {
return status === 'FAILED' || status === 'FAILED_ACKNOWLEDGED';
}
function hasNewFailedChecks(status: HealthcheckStatus) {
return status === 'FAILED';
}
export type State = {
acknowledgeCheckboxVisible: boolean;
acknowledgeOnClose?: boolean;
};
type Props = OwnProps & StateFromProps & DispatchFromProps; type Props = OwnProps & StateFromProps & DispatchFromProps;
class DoctorSheet extends Component<Props, State> { class DoctorSheet extends Component<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = {}; this.state = {
acknowledgeCheckboxVisible: false,
};
}
static getDerivedStateFromProps(props: Props, state: State): State | null {
if (
!state.acknowledgeCheckboxVisible &&
hasFailedChecks(props.healthcheckReport.status)
) {
return {
...state,
acknowledgeCheckboxVisible: true,
acknowledgeOnClose:
state.acknowledgeOnClose === undefined
? !hasNewFailedChecks(props.healthcheckReport.status)
: state.acknowledgeOnClose,
};
}
if (
state.acknowledgeCheckboxVisible &&
!hasFailedChecks(props.healthcheckReport.status)
) {
return {
...state,
acknowledgeCheckboxVisible: false,
};
}
return null;
}
componentWillUnmount(): void {
if (this.state.acknowledgeOnClose) {
this.props.acknowledgeProblems();
} else {
this.props.resetAcknowledgedProblems();
}
}
onAcknowledgeOnCloseChanged(acknowledge: boolean): void {
this.setState(prevState => {
return {
...prevState,
acknowledgeOnClose: acknowledge,
};
});
} }
openHelpUrl(check: HealthcheckReportItem): void { openHelpUrl(check: HealthcheckReportItem): void {
@@ -195,33 +301,21 @@ class DoctorSheet extends Component<Props, State> {
} }
async runHealthchecks() { async runHealthchecks() {
this.setState(prevState => {
return {
...prevState,
};
});
await runHealthchecks(this.props); await runHealthchecks(this.props);
} }
hasProblems() {
return this.props.report.categories.some(cat =>
cat.checks.some(
chk => chk.status === 'FAILED' || chk.status === 'WARNING',
),
);
}
render() { render() {
return ( return (
<Container> <Container>
<Title>Doctor</Title> <Title>Doctor</Title>
<FlexRow> <FlexRow>
<HealthcheckListContainer> <HealthcheckListContainer>
{Object.values(this.props.report.categories).map( {Object.values(this.props.healthcheckReport.categories).map(
(category, categoryIdx) => { (category: HealthcheckReportCategory, categoryIdx: number) => {
return ( return (
<CategoryContainer key={categoryIdx}> <CategoryContainer key={categoryIdx}>
<HealthcheckDisplay check={category} category={category} /> <HealthcheckDisplay check={category} category={category} />
{category.status !== 'SKIPPED' && (
<CategoryContainer> <CategoryContainer>
{category.checks.map((check, checkIdx) => ( {category.checks.map((check, checkIdx) => (
<HealthcheckDisplay <HealthcheckDisplay
@@ -236,6 +330,12 @@ class DoctorSheet extends Component<Props, State> {
/> />
))} ))}
</CategoryContainer> </CategoryContainer>
)}
{category.status === 'SKIPPED' && (
<CategoryContainer>
<SkipReasonLabel>{category.message}</SkipReasonLabel>
</CategoryContainer>
)}
</CategoryContainer> </CategoryContainer>
); );
}, },
@@ -245,19 +345,28 @@ class DoctorSheet extends Component<Props, State> {
<SideContainer shrink> <SideContainer shrink>
<SideMessageDisplay <SideMessageDisplay
isHealthcheckInProgress={ isHealthcheckInProgress={
this.props.report.isHealthcheckInProgress this.props.healthcheckReport.status === 'IN_PROGRESS'
} }
hasProblems={this.hasProblems()} hasProblems={hasProblems(this.props.healthcheckReport.status)}
/> />
</SideContainer> </SideContainer>
</FlexRow> </FlexRow>
<FlexRow> <FlexRow>
<Spacer /> <Spacer />
{this.state.acknowledgeCheckboxVisible && (
<CenteredCheckbox
checked={!!this.state.acknowledgeOnClose}
onChange={this.onAcknowledgeOnCloseChanged.bind(this)}
text={
'Do not show warning about these problems on Flipper startup'
}
/>
)}
<Button compact padded onClick={this.props.onHide}> <Button compact padded onClick={this.props.onHide}>
Close Close
</Button> </Button>
<Button <Button
disabled={this.props.report.isHealthcheckInProgress} disabled={this.props.healthcheckReport.status === 'IN_PROGRESS'}
type="primary" type="primary"
compact compact
padded padded
@@ -272,14 +381,14 @@ class DoctorSheet extends Component<Props, State> {
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>( export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
({healthchecks: {healthcheckReport}, settingsState}) => ({ ({healthchecks: {healthcheckReport}, settingsState}) => ({
report: healthcheckReport, healthcheckReport,
enableAndroid: settingsState.enableAndroid, enableAndroid: settingsState.enableAndroid,
}), }),
{ {
initHealthcheckReport,
updateHealthcheckReportItemStatus,
updateHealthcheckReportCategoryStatus,
startHealthchecks, startHealthchecks,
finishHealthchecks, finishHealthchecks,
updateHealthcheckResult,
acknowledgeProblems,
resetAcknowledgedProblems,
}, },
)(DoctorSheet); )(DoctorSheet);

View File

@@ -9,105 +9,195 @@
import { import {
default as reducer, default as reducer,
initHealthcheckReport,
startHealthchecks, startHealthchecks,
HealthcheckReportCategory,
HealthcheckReportItem,
finishHealthchecks, finishHealthchecks,
updateHealthcheckReportItemStatus, updateHealthcheckResult,
updateHealthcheckReportCategoryStatus, acknowledgeProblems,
} from '../healthchecks'; } from '../healthchecks';
import {Healthchecks} from 'flipper-doctor';
import {EnvironmentInfo} from 'flipper-doctor/lib/environmentInfo';
const HEALTHCHECK_ITEM: HealthcheckReportItem = { const HEALTHCHECKS: Healthchecks = {
label: 'Test Check', ios: {
status: 'WARNING', label: 'iOS',
message: "Something didn't quite work.", isSkipped: false,
isRequired: true,
healthchecks: [
{
label: 'SDK Installed',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: false};
},
},
],
},
android: {
label: 'Android',
isSkipped: false,
isRequired: true,
healthchecks: [
{
label: 'SDK Installed',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: true};
},
},
],
},
common: {
label: 'Common',
isSkipped: false,
isRequired: false,
healthchecks: [
{
label: 'OpenSSL Istalled',
run: async (_env: EnvironmentInfo) => {
return {hasProblem: false};
},
},
],
},
}; };
const HEALTHCHECK_CATEGORY: HealthcheckReportCategory = {
label: 'Test Category',
status: 'WARNING',
checks: [HEALTHCHECK_ITEM],
};
test('initHealthcheckReport', () => {
const report = {
isHealthcheckInProgress: false,
categories: [],
};
const res = reducer(undefined, initHealthcheckReport(report));
expect(res.healthcheckReport).toEqual(report);
});
test('startHealthCheck', () => { test('startHealthCheck', () => {
const report = { const res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
isHealthcheckInProgress: false, expect(res.healthcheckReport.status).toBe('IN_PROGRESS');
categories: [HEALTHCHECK_CATEGORY], expect(res.healthcheckReport.categories.length).toBe(3);
}; expect(res.healthcheckReport.categories[0].status).toEqual('IN_PROGRESS');
let res = reducer(undefined, initHealthcheckReport(report)); expect(res.healthcheckReport.categories[0].label).toEqual('iOS');
res = reducer(res, startHealthchecks()); expect(res.healthcheckReport.categories[0].checks.length).toEqual(1);
expect(res.healthcheckReport.isHealthcheckInProgress).toBeTruthy(); expect(res.healthcheckReport.categories[0].checks[0].label).toEqual(
// This seems trivial, but by getting the spread wrong, it's easy 'SDK Installed',
// to break this. );
expect(res.healthcheckReport.categories).toEqual([HEALTHCHECK_CATEGORY]); expect(res.healthcheckReport.categories[0].checks[0].status).toEqual(
'IN_PROGRESS',
);
}); });
test('finish', () => { test('updateHealthcheckResult', () => {
const report = { let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
isHealthcheckInProgress: true,
categories: [HEALTHCHECK_CATEGORY],
};
let res = reducer(undefined, initHealthcheckReport(report));
res = reducer(res, finishHealthchecks());
expect(res.healthcheckReport.isHealthcheckInProgress).toBeFalsy();
expect(res.healthcheckReport.categories).toEqual([HEALTHCHECK_CATEGORY]);
});
test('updateHealthcheck', () => {
const report = {
isHealthcheckInProgress: true,
categories: [HEALTHCHECK_CATEGORY, HEALTHCHECK_CATEGORY],
};
let res = reducer(undefined, initHealthcheckReport(report));
res = reducer( res = reducer(
res, res,
updateHealthcheckReportItemStatus(0, 0, { updateHealthcheckResult(0, 0, {
message: 'Updated Test Message', message: 'Updated Test Message',
status: 'SUCCESS', status: 'SUCCESS',
}), }),
); );
expect(res.healthcheckReport.isHealthcheckInProgress).toBeTruthy(); expect(res.healthcheckReport.status).toBe('IN_PROGRESS');
expect(res.healthcheckReport.categories[0].checks[0].message).toEqual( expect(res.healthcheckReport.categories[0].checks[0].message).toEqual(
'Updated Test Message', 'Updated Test Message',
); );
expect(res.healthcheckReport.categories[0].checks[0].status).toEqual( expect(res.healthcheckReport.categories[0].checks[0].status).toEqual(
'SUCCESS', 'SUCCESS',
); );
expect(res.healthcheckReport.categories[1].checks[0].label).toEqual( expect(res.healthcheckReport.categories[0].status).toEqual('IN_PROGRESS');
'Test Check', expect(res.healthcheckReport.categories[1].checks[0].message).toBeUndefined();
expect(res.healthcheckReport.categories[1].checks[0].status).toEqual(
'IN_PROGRESS',
);
expect(res.healthcheckReport.categories[1].status).toEqual('IN_PROGRESS');
});
test('finish', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult(0, 0, {
message: 'Updated Test Message',
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult(1, 0, {
message: 'Updated Test Message',
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult(2, 0, {
message: 'Updated Test Message',
status: 'SUCCESS',
}),
);
res = reducer(res, finishHealthchecks());
expect(res.healthcheckReport.status).toBe('SUCCESS');
expect(res.healthcheckReport.categories.map(c => c.status)).toEqual([
'SUCCESS',
'SUCCESS',
'SUCCESS',
]);
});
test('statuses updated after healthchecks finished', () => {
let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
res = reducer(
res,
updateHealthcheckResult(1, 0, {
message: 'Updated Test Message',
status: 'FAILED',
}),
);
res = reducer(
res,
updateHealthcheckResult(0, 0, {
message: 'Updated Test Message',
status: 'SUCCESS',
}),
);
res = reducer(
res,
updateHealthcheckResult(2, 0, {
message: 'Updated Test Message',
status: 'SUCCESS',
}),
);
res = reducer(res, finishHealthchecks());
expect(res.healthcheckReport.status).toBe('FAILED');
expect(res.healthcheckReport.categories.map(c => c.status)).toEqual([
'SUCCESS',
'FAILED',
'SUCCESS',
]);
expect(res.healthcheckReport.categories[1].checks[0].message).toEqual(
'Updated Test Message',
); );
expect(res.healthcheckReport.categories[1].checks[0].status).toEqual( expect(res.healthcheckReport.categories[1].checks[0].status).toEqual(
'WARNING', 'FAILED',
); );
}); });
test('updateHealthcheckCategoryStatus', () => { test('acknowledgeProblems', () => {
const report = { let res = reducer(undefined, startHealthchecks(HEALTHCHECKS));
isHealthcheckInProgress: true,
categories: [HEALTHCHECK_CATEGORY, HEALTHCHECK_CATEGORY],
};
let res = reducer(undefined, initHealthcheckReport(report));
res = reducer( res = reducer(
res, res,
updateHealthcheckReportCategoryStatus(1, { updateHealthcheckResult(0, 0, {
status: 'FAILED', status: 'FAILED',
message: 'Error message',
}), }),
); );
expect(res.healthcheckReport.isHealthcheckInProgress).toBeTruthy(); res = reducer(
expect(res.healthcheckReport.categories[0].label).toEqual('Test Category'); res,
expect(res.healthcheckReport.categories[0].status).toEqual('WARNING'); updateHealthcheckResult(1, 0, {
expect(res.healthcheckReport.categories[1].label).toEqual('Test Category'); status: 'SUCCESS',
expect(res.healthcheckReport.categories[1].status).toEqual('FAILED'); }),
expect(res.healthcheckReport.categories[1].message).toEqual('Error message'); );
res = reducer(
res,
updateHealthcheckResult(2, 0, {
status: 'FAILED',
}),
);
res = reducer(res, finishHealthchecks());
res = reducer(res, acknowledgeProblems());
expect(res.healthcheckReport.categories[0].status).toEqual(
'FAILED_ACKNOWLEDGED',
);
expect(res.healthcheckReport.categories[0].checks[0].status).toEqual(
'FAILED_ACKNOWLEDGED',
);
expect(res.healthcheckReport.categories[1].status).toEqual('SUCCESS');
expect(res.healthcheckReport.categories[2].status).toEqual(
'FAILED_ACKNOWLEDGED',
);
}); });

View File

@@ -7,51 +7,51 @@
* @format * @format
*/ */
import {produce} from 'immer';
import {Actions} from './'; import {Actions} from './';
import {produce} from 'flipper';
import {Healthchecks} from 'flipper-doctor';
export type State = { export type State = {
healthcheckReport: HealthcheckReport; healthcheckReport: HealthcheckReport;
acknowledgedProblems: string[];
}; };
export type Action = export type Action =
| {
type: 'INIT_HEALTHCHECK_REPORT';
payload: HealthcheckReport;
}
| {
type: 'UPDATE_HEALTHCHECK_REPORT_ITEM_STATUS';
payload: {
categoryIdx: number;
itemIdx: number;
status: HealthcheckResult;
};
}
| {
type: 'UPDATE_HEALTHCHECK_REPORT_CATEGORY_STATUS';
payload: {
categoryIdx: number;
status: HealthcheckResult;
};
}
| { | {
type: 'START_HEALTHCHECKS'; type: 'START_HEALTHCHECKS';
payload: Healthchecks;
} }
| { | {
type: 'FINISH_HEALTHCHECKS'; type: 'FINISH_HEALTHCHECKS';
}
| {
type: 'UPDATE_HEALTHCHECK_RESULT';
payload: {
categoryIdx: number;
itemIdx: number;
result: HealthcheckResult;
};
}
| {
type: 'ACKNOWLEDGE_PROBLEMS';
}
| {
type: 'RESET_ACKNOWLEDGED_PROBLEMS';
}; };
const INITIAL_STATE: State = { const INITIAL_STATE: State = {
healthcheckReport: { healthcheckReport: {
isHealthcheckInProgress: false, status: 'IN_PROGRESS',
categories: [], categories: [],
}, },
acknowledgedProblems: [],
}; };
export type HealthcheckStatus = export type HealthcheckStatus =
| 'IN_PROGRESS' | 'IN_PROGRESS'
| 'SUCCESS' | 'SUCCESS'
| 'FAILED' | 'FAILED'
| 'FAILED_ACKNOWLEDGED'
| 'SKIPPED' | 'SKIPPED'
| 'WARNING'; | 'WARNING';
@@ -71,95 +71,194 @@ export type HealthcheckReportCategory = {
} & HealthcheckResult; } & HealthcheckResult;
export type HealthcheckReport = { export type HealthcheckReport = {
isHealthcheckInProgress: boolean; status: HealthcheckStatus;
categories: Array<HealthcheckReportCategory>; categories: Array<HealthcheckReportCategory>;
}; };
const updateReportItem = produce( function getHealthcheckIdentifier(
category: HealthcheckReportCategory,
item: HealthcheckReportItem,
) {
return `${category.label} : ${item.label}`;
}
function recomputeHealthcheckStatus(draft: State): void {
draft.healthcheckReport.status = computeAggregatedStatus(
draft.healthcheckReport.categories.map(c => c.status),
);
}
function computeAggregatedStatus(
statuses: HealthcheckStatus[],
): HealthcheckStatus {
return statuses.some(s => s === 'IN_PROGRESS')
? 'IN_PROGRESS'
: statuses.every(s => s === 'SUCCESS')
? 'SUCCESS'
: statuses.some(s => s === 'FAILED')
? 'FAILED'
: statuses.some(s => s === 'FAILED_ACKNOWLEDGED')
? 'FAILED_ACKNOWLEDGED'
: statuses.some(s => s === 'WARNING')
? 'WARNING'
: 'SKIPPED';
}
const updateCheckResult = produce(
( (
draft: State, draft: State,
payload: { {
categoryIdx,
itemIdx,
result,
}: {
categoryIdx: number; categoryIdx: number;
itemIdx: number; itemIdx: number;
status: HealthcheckResult; result: HealthcheckResult;
}, },
) => { ) => {
Object.assign( const category = draft.healthcheckReport.categories[categoryIdx];
draft.healthcheckReport.categories[payload.categoryIdx].checks[ const item = category.checks[itemIdx];
payload.itemIdx Object.assign(item, result);
], if (
payload.status, result.status === 'FAILED' &&
); draft.acknowledgedProblems.includes(
getHealthcheckIdentifier(category, item),
)
) {
item.status = 'FAILED_ACKNOWLEDGED';
}
}, },
); );
const updateCategoryStatus = produce( const start = produce((draft: State, healthchecks: Healthchecks) => {
(draft: State, payload: {categoryIdx: number; status: HealthcheckResult}) => { draft.healthcheckReport = {
Object.assign( status: 'IN_PROGRESS',
draft.healthcheckReport.categories[payload.categoryIdx], categories: Object.values(healthchecks)
payload.status, .map(category => {
); if (category.isSkipped) {
}, return {
); status: 'SKIPPED',
label: category.label,
const initReport = produce((draft: State, report: HealthcheckReport) => { checks: [],
draft.healthcheckReport = report; message: category.skipReason,
};
}
return {
status: 'IN_PROGRESS',
label: category.label,
checks: category.healthchecks.map(x => ({
status: 'IN_PROGRESS',
label: x.label,
})),
};
})
.filter(x => !!x)
.map(x => x as HealthcheckReportCategory),
};
}); });
const setIsInProgress = produce((draft: State, isInProgress: boolean) => { const finish = produce((draft: State) => {
draft.healthcheckReport.isHealthcheckInProgress = isInProgress; draft.healthcheckReport.categories
.filter(cat => cat.status !== 'SKIPPED')
.forEach(cat => {
cat.message = undefined;
cat.status = computeAggregatedStatus(cat.checks.map(c => c.status));
});
recomputeHealthcheckStatus(draft);
if (draft.healthcheckReport.status === 'SUCCESS') {
setAcknowledgedProblemsToEmpty(draft);
}
});
const acknowledge = produce((draft: State) => {
draft.acknowledgedProblems = ([] as string[]).concat(
...draft.healthcheckReport.categories.map(cat =>
cat.checks
.filter(
chk =>
chk.status === 'FAILED' ||
chk.status === 'FAILED_ACKNOWLEDGED' ||
chk.status === 'WARNING',
)
.map(chk => getHealthcheckIdentifier(cat, chk)),
),
);
draft.healthcheckReport.categories.forEach(cat => {
if (cat.status === 'FAILED') {
cat.status = 'FAILED_ACKNOWLEDGED';
}
cat.checks.forEach(chk => {
if (chk.status == 'FAILED') {
chk.status = 'FAILED_ACKNOWLEDGED';
}
});
});
recomputeHealthcheckStatus(draft);
});
function setAcknowledgedProblemsToEmpty(draft: State) {
draft.acknowledgedProblems = [];
draft.healthcheckReport.categories.forEach(cat => {
if (cat.status === 'FAILED_ACKNOWLEDGED') {
cat.status = 'FAILED';
}
cat.checks.forEach(chk => {
if (chk.status == 'FAILED_ACKNOWLEDGED') {
chk.status = 'FAILED';
}
});
});
}
const resetAcknowledged = produce((draft: State) => {
setAcknowledgedProblemsToEmpty(draft);
recomputeHealthcheckStatus(draft);
}); });
export default function reducer( export default function reducer(
draft: State | undefined = INITIAL_STATE, draft: State | undefined = INITIAL_STATE,
action: Actions, action: Actions,
): State { ): State {
return action.type === 'INIT_HEALTHCHECK_REPORT' return action.type === 'START_HEALTHCHECKS'
? initReport(draft, action.payload) ? start(draft, action.payload)
: action.type === 'START_HEALTHCHECKS'
? setIsInProgress(draft, true)
: action.type === 'FINISH_HEALTHCHECKS' : action.type === 'FINISH_HEALTHCHECKS'
? setIsInProgress(draft, false) ? finish(draft)
: action.type === 'UPDATE_HEALTHCHECK_REPORT_ITEM_STATUS' : action.type === 'UPDATE_HEALTHCHECK_RESULT'
? updateReportItem(draft, action.payload) ? updateCheckResult(draft, action.payload)
: action.type === 'UPDATE_HEALTHCHECK_REPORT_CATEGORY_STATUS' : action.type === 'ACKNOWLEDGE_PROBLEMS'
? updateCategoryStatus(draft, action.payload) ? acknowledge(draft)
: action.type === 'RESET_ACKNOWLEDGED_PROBLEMS'
? resetAcknowledged(draft)
: draft; : draft;
} }
export const initHealthcheckReport = (report: HealthcheckReport): Action => ({ export const updateHealthcheckResult = (
type: 'INIT_HEALTHCHECK_REPORT',
payload: report,
});
export const updateHealthcheckReportItemStatus = (
categoryIdx: number, categoryIdx: number,
itemIdx: number, itemIdx: number,
status: HealthcheckResult, result: HealthcheckResult,
): Action => ({ ): Action => ({
type: 'UPDATE_HEALTHCHECK_REPORT_ITEM_STATUS', type: 'UPDATE_HEALTHCHECK_RESULT',
payload: { payload: {
categoryIdx, categoryIdx,
itemIdx, itemIdx,
status, result,
}, },
}); });
export const updateHealthcheckReportCategoryStatus = ( export const startHealthchecks = (healthchecks: Healthchecks): Action => ({
categoryIdx: number,
status: HealthcheckResult,
): Action => ({
type: 'UPDATE_HEALTHCHECK_REPORT_CATEGORY_STATUS',
payload: {
categoryIdx,
status,
},
});
export const startHealthchecks = (): Action => ({
type: 'START_HEALTHCHECKS', type: 'START_HEALTHCHECKS',
payload: healthchecks,
}); });
export const finishHealthchecks = (): Action => ({ export const finishHealthchecks = (): Action => ({
type: 'FINISH_HEALTHCHECKS', type: 'FINISH_HEALTHCHECKS',
}); });
export const acknowledgeProblems = (): Action => ({
type: 'ACKNOWLEDGE_PROBLEMS',
});
export const resetAcknowledgedProblems = (): Action => ({
type: 'RESET_ACKNOWLEDGED_PROBLEMS',
});

View File

@@ -92,7 +92,7 @@ export type State = {
launcherSettingsState: LauncherSettingsState & PersistPartial; launcherSettingsState: LauncherSettingsState & PersistPartial;
supportForm: SupportFormState; supportForm: SupportFormState;
pluginManager: PluginManagerState; pluginManager: PluginManagerState;
healthchecks: HealthcheckState; healthchecks: HealthcheckState & PersistPartial;
}; };
export type Store = ReduxStore<State, Actions>; export type Store = ReduxStore<State, Actions>;
@@ -159,5 +159,12 @@ export default combineReducers<State, Actions>({
}, },
launcherSettings, launcherSettings,
), ),
healthchecks: persistReducer<HealthcheckState, Actions>(
{
key: 'healthchecks',
storage,
whitelist: ['acknowledgedProblems'],
},
healthchecks, healthchecks,
),
}); });

View File

@@ -7,28 +7,19 @@
* @format * @format
*/ */
import { import {HealthcheckResult} from '../reducers/healthchecks';
HealthcheckResult, import {getHealthchecks, getEnvInfo, Healthchecks} from 'flipper-doctor';
HealthcheckReport,
HealthcheckReportCategory,
} from '../reducers/healthchecks';
import {getHealthchecks, getEnvInfo} from 'flipper-doctor';
let healthcheckIsRunning: boolean; let healthcheckIsRunning: boolean;
let runningHealthcheck: Promise<boolean>; let runningHealthcheck: Promise<void>;
export type HealthcheckEventsHandler = { export type HealthcheckEventsHandler = {
initHealthcheckReport: (report: HealthcheckReport) => void; updateHealthcheckResult: (
updateHealthcheckReportItemStatus: (
categoryIdx: number, categoryIdx: number,
itemIdx: number, itemIdx: number,
status: HealthcheckResult, result: HealthcheckResult,
) => void; ) => void;
updateHealthcheckReportCategoryStatus: ( startHealthchecks: (healthchecks: Healthchecks) => void;
categoryIdx: number,
status: HealthcheckResult,
) => void;
startHealthchecks: () => void;
finishHealthchecks: () => void; finishHealthchecks: () => void;
}; };
@@ -38,132 +29,51 @@ export type HealthcheckSettings = {
export type HealthcheckOptions = HealthcheckEventsHandler & HealthcheckSettings; export type HealthcheckOptions = HealthcheckEventsHandler & HealthcheckSettings;
async function launchHealthchecks( async function launchHealthchecks(options: HealthcheckOptions): Promise<void> {
options: HealthcheckOptions,
): Promise<boolean> {
let healthchecksResult: boolean = true;
options.startHealthchecks();
try {
const inProgressResult: HealthcheckResult = {
status: 'IN_PROGRESS',
message: 'The healthcheck is in progress',
};
const androidSkippedResult: HealthcheckResult = {
status: 'SKIPPED',
message:
'The healthcheck was skipped because Android development is disabled in the Flipper settings',
};
const failedResult: HealthcheckResult = {
status: 'FAILED',
message: 'The healthcheck failed',
};
const warningResult: HealthcheckResult = {
status: 'WARNING',
message: 'The optional healthcheck failed',
};
const succeededResult: HealthcheckResult = {
status: 'SUCCESS',
message: undefined,
};
const healthchecks = getHealthchecks(); const healthchecks = getHealthchecks();
const hcState: HealthcheckReport = { if (!options.enableAndroid) {
isHealthcheckInProgress: true, healthchecks.android = {
categories: Object.entries(healthchecks) label: healthchecks.android.label,
.map(([categoryKey, category]) => { isSkipped: true,
if (!category) { skipReason:
return null; 'Healthcheck is skipped, because "Android Development" option is disabled in the Flipper settings',
};
} }
const state: HealthcheckResult = options.startHealthchecks(healthchecks);
categoryKey === 'android' && !options.enableAndroid
? androidSkippedResult
: inProgressResult;
return {
...state,
label: category.label,
checks: category.healthchecks.map(x => ({
...state,
label: x.label,
})),
};
})
.filter(x => !!x)
.map(x => x as HealthcheckReportCategory),
};
options.initHealthcheckReport(hcState);
const environmentInfo = await getEnvInfo(); const environmentInfo = await getEnvInfo();
const categories = Object.entries(healthchecks); const categories = Object.values(healthchecks);
for (const [categoryIdx, [categoryKey, category]] of categories.entries()) { for (const [categoryIdx, category] of categories.entries()) {
if (!category) { if (category.isSkipped) {
continue; continue;
} }
const isSkippedAndroidCategory =
categoryKey === 'android' && !options.enableAndroid;
const allResults: HealthcheckResult[] = [];
for ( for (
let healthcheckIdx = 0; let healthcheckIdx = 0;
healthcheckIdx < category.healthchecks.length; healthcheckIdx < category.healthchecks.length;
healthcheckIdx++ healthcheckIdx++
) { ) {
const h = category.healthchecks[healthcheckIdx]; const h = category.healthchecks[healthcheckIdx];
if (isSkippedAndroidCategory) { const checkResult = await h.run(environmentInfo);
options.updateHealthcheckReportItemStatus( const result: HealthcheckResult =
categoryIdx, checkResult.hasProblem && h.isRequired
healthcheckIdx,
androidSkippedResult,
);
allResults.push(androidSkippedResult);
} else {
const result = await h.run(environmentInfo);
if (result.hasProblem && h.isRequired) {
healthchecksResult = false;
}
const status: HealthcheckResult =
result.hasProblem && h.isRequired
? { ? {
...failedResult, status: 'FAILED',
helpUrl: result.helpUrl, helpUrl: checkResult.helpUrl,
} }
: result.hasProblem && !h.isRequired : checkResult.hasProblem && !h.isRequired
? { ? {
...warningResult, status: 'WARNING',
helpUrl: result.helpUrl, helpUrl: checkResult.helpUrl,
} }
: succeededResult; : {status: 'SUCCESS'};
options.updateHealthcheckReportItemStatus( options.updateHealthcheckResult(categoryIdx, healthcheckIdx, result);
categoryIdx,
healthcheckIdx,
status,
);
allResults.push(status);
} }
} }
const categoryStatus = {
label: category.label,
...(allResults.some(c => c.status === 'IN_PROGRESS')
? inProgressResult
: allResults.every(c => c.status === 'SUCCESS')
? succeededResult
: allResults.every(c => c.status === 'SKIPPED')
? androidSkippedResult
: allResults.some(c => c.status === 'FAILED')
? failedResult
: warningResult),
};
options.updateHealthcheckReportCategoryStatus(
categoryIdx,
categoryStatus,
);
}
} catch {
} finally {
options.finishHealthchecks(); options.finishHealthchecks();
}
return healthchecksResult;
} }
export default async function runHealthchecks( export default async function runHealthchecks(
options: HealthcheckOptions, options: HealthcheckOptions,
): Promise<boolean> { ): Promise<void> {
if (healthcheckIsRunning) { if (healthcheckIsRunning) {
return runningHealthcheck; return runningHealthcheck;
} }

View File

@@ -4220,10 +4220,10 @@ flatted@^2.0.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08"
integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==
flipper-doctor@^0.2.4: flipper-doctor@^0.4.1:
version "0.2.4" version "0.4.1"
resolved "https://registry.yarnpkg.com/flipper-doctor/-/flipper-doctor-0.2.4.tgz#907c14282a9f65737f96505dfb0c5022109a10fe" resolved "https://registry.yarnpkg.com/flipper-doctor/-/flipper-doctor-0.4.1.tgz#bc4ce11a920e983da04e492ba04d5fe108659bb5"
integrity sha512-MjX66Yd1dPSLcnOXIfqPaB5KXmd+A7f+99ashGhbRV+2Y8bXadN4kvYrUZ4gD177LmtMdNjISjm87kBt5t9pnw== integrity sha512-1N7wgM03i1aHgTgMiv4V1mWkfkfoAgbBQmt4ai7AW2rhb2ocmy6p+8I51L2rJEOVmsRP/oDafpdgVNm1ggHp4w==
dependencies: dependencies:
"@types/node" "^12.12.12" "@types/node" "^12.12.12"
envinfo "^7.4.0" envinfo "^7.4.0"