dev: refactor installAndActivatePlugins to xstate (#38577)

* dev: refactor installAndActivatePlugins to xstate

* ts fixes

* fixed first plugin install progress bar reset issue

* clean up todo comments

* fixed bug where errors were not reported if past 30s timeout

* fix lint
This commit is contained in:
RJ 2023-06-09 17:08:45 +10:00 committed by GitHub
parent b04376b501
commit 622711c48b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 347 additions and 148 deletions

View File

@ -49,13 +49,13 @@ import { BusinessLocation } from './pages/BusinessLocation';
import { getCountryStateOptions } from './services/country';
import { Loader } from './pages/Loader';
import { Plugins } from './pages/Plugins';
import { getPluginTrackKey, getTimeFrame } from '~/utils';
import { getPluginSlug, getPluginTrackKey, getTimeFrame } from '~/utils';
import './style.scss';
import {
InstallationCompletedResult,
InstallAndActivatePlugins,
InstalledPlugin,
PluginInstallError,
pluginInstallerMachine,
} from './services/installAndActivatePlugins';
import { ProfileSpinner } from './components/profile-spinner/profile-spinner';
import recordTracksActions from './actions/tracks';
@ -362,6 +362,23 @@ const updateBusinessLocation = ( countryAndState: string ) => {
} );
};
const assignStoreLocation = assign( {
businessInfo: (
context: CoreProfilerStateMachineContext,
event: BusinessLocationEvent
) => {
return {
...context.businessInfo,
location: event.payload.storeLocation,
};
},
} );
const assignUserProfile = assign( {
userProfile: ( context, event: UserProfileEvent ) =>
event.payload.userProfile, // sets context.userProfile to the payload of the event
} );
const updateBusinessInfo = async (
_context: CoreProfilerStateMachineContext,
event: BusinessInfoEvent
@ -442,8 +459,8 @@ const browserPopstateHandler = () => ( sendBack: Sender< AnyEventObject > ) => {
};
const handlePlugins = assign( {
pluginsAvailable: ( _context, event: DoneInvokeEvent< Extension[] > ) =>
event.data,
pluginsAvailable: ( _context, event ) =>
( event as DoneInvokeEvent< Extension[] > ).data,
} );
type ActType = (
@ -462,6 +479,12 @@ const updateQueryStep: ActType = ( _context, _evt, { action } ) => {
}
};
const assignPluginsSelected = assign( {
pluginsSelected: ( _context, event: PluginsInstallationRequestedEvent ) => {
return event.payload.plugins.map( getPluginSlug );
},
} );
export const preFetchActions = {
preFetchGetPlugins,
preFetchGetCountries,
@ -480,6 +503,9 @@ const coreProfilerMachineActions = {
handleStoreNameOption,
handleStoreCountryOption,
assignOptInDataSharing,
assignStoreLocation,
assignPluginsSelected,
assignUserProfile,
handleCountries,
handleOnboardingProfileOption,
assignOnboardingProfile,
@ -712,25 +738,11 @@ export const coreProfilerStateMachineDefinition = createMachine( {
on: {
USER_PROFILE_COMPLETED: {
target: 'postUserProfile',
actions: [
assign( {
userProfile: (
_context,
event: UserProfileEvent
) => event.payload.userProfile, // sets context.userProfile to the payload of the event
} ),
],
actions: [ 'assignUserProfile' ],
},
USER_PROFILE_SKIPPED: {
target: 'postUserProfile',
actions: [
assign( {
userProfile: (
_context,
event: UserProfileEvent
) => event.payload.userProfile, // assign context.userProfile to the payload of the event
} ),
],
actions: [ 'assignUserProfile' ],
},
},
exit: actions.choose( [
@ -952,18 +964,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
BUSINESS_LOCATION_COMPLETED: {
target: 'postSkipFlowBusinessLocation',
actions: [
assign( {
businessInfo: (
_context,
event: BusinessLocationEvent
) => {
return {
..._context.businessInfo,
location:
event.payload.storeLocation,
};
},
} ),
'assignStoreLocation',
'recordTracksSkipBusinessLocationCompleted',
],
},
@ -1108,14 +1109,7 @@ export const coreProfilerStateMachineDefinition = createMachine( {
},
PLUGINS_INSTALLATION_REQUESTED: {
target: 'installPlugins',
actions: [
assign( {
pluginsSelected: (
_context,
event: PluginsInstallationRequestedEvent
) => event.payload.plugins,
} ),
],
actions: [ 'assignPluginsSelected' ],
},
},
meta: {
@ -1268,7 +1262,12 @@ export const coreProfilerStateMachineDefinition = createMachine( {
} ),
],
invoke: {
src: InstallAndActivatePlugins,
src: pluginInstallerMachine,
data: ( context ) => {
return {
selectedPlugins: context.pluginsSelected,
};
},
},
meta: {
component: Loader,
@ -1290,6 +1289,8 @@ export const CoreProfilerController = ( {
const augmentedStateMachine = useMemo( () => {
// When adding extensibility, this is the place to manipulate the state machine definition.
return coreProfilerStateMachineDefinition.withConfig( {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- there seems to be a flaky error here - it fails sometimes and then not on recompile, will need to investigate further.
actions: {
...coreProfilerMachineActions,
...actionOverrides,

View File

@ -3,7 +3,13 @@
*/
import { PLUGINS_STORE_NAME, PluginNames } from '@woocommerce/data';
import { dispatch } from '@wordpress/data';
import { differenceWith } from 'lodash';
import {
assign,
createMachine,
sendParent,
actions,
DoneInvokeEvent,
} from 'xstate';
/**
* Internal dependencies
@ -12,9 +18,9 @@ import {
PluginInstalledAndActivatedEvent,
PluginsInstallationCompletedEvent,
PluginsInstallationCompletedWithErrorsEvent,
CoreProfilerStateMachineContext,
} from '..';
import { getPluginSlug } from '~/utils';
const { pure } = actions;
export type InstalledPlugin = {
plugin: string;
@ -60,109 +66,207 @@ const createPluginInstalledAndActivatedEvent = (
},
} );
export const InstallAndActivatePlugins =
( context: CoreProfilerStateMachineContext ) =>
async (
send: (
event:
| PluginInstalledAndActivatedEvent
| PluginsInstallationCompletedEvent
| PluginsInstallationCompletedWithErrorsEvent
) => void
) => {
let continueInstallation = true;
const errors: PluginInstallError[] = [];
const installationCompletedResult: InstallationCompletedResult = {
installedPlugins: [],
totalTime: 0,
};
const installationStartTime = window.performance.now();
const setInstallationCompletedTime = () => {
installationCompletedResult.totalTime =
window.performance.now() - installationStartTime;
};
export type PluginInstallerMachineContext = {
selectedPlugins: PluginNames[];
pluginsInstallationQueue: PluginNames[];
installedPlugins: InstalledPlugin[];
startTime: number;
installationDuration: number;
errors: PluginInstallError[];
};
const handleInstallationCompleted = () => {
if ( errors.length ) {
return send(
createInstallationCompletedWithErrorsEvent( errors )
type InstallAndActivateErrorResponse = DoneInvokeEvent< {
error: string;
message: string;
} >;
type InstallAndActivateSuccessResponse = DoneInvokeEvent< {
installed: PluginNames[];
results: Record< PluginNames, boolean >;
install_time: Record< PluginNames, number >;
} >;
export const pluginInstallerMachine = createMachine(
{
id: 'plugin-installer',
predictableActionArguments: true,
initial: 'installing',
context: {
selectedPlugins: [] as PluginNames[],
pluginsInstallationQueue: [] as PluginNames[],
installedPlugins: [] as InstalledPlugin[],
startTime: 0,
installationDuration: 0,
errors: [] as PluginInstallError[],
} as PluginInstallerMachineContext,
states: {
installing: {
initial: 'installer',
entry: [
'populateDefaults',
'assignPluginsInstallationQueue',
'assignStartTime',
],
after: {
INSTALLATION_TIMEOUT: 'timedOut',
},
states: {
installer: {
initial: 'installing',
states: {
installing: {
invoke: {
src: 'installPlugin',
onDone: {
actions: [
'assignInstallationSuccessDetails',
],
target: 'removeFromQueue',
},
onError: {
actions:
'assignInstallationErrorDetails',
target: 'removeFromQueue',
},
},
},
removeFromQueue: {
entry: [
'removePluginFromQueue',
'updateParentWithPluginProgress',
],
always: [
{
target: 'installing',
cond: 'hasPluginsToInstall',
},
{ target: '#installation-finished' },
],
},
},
},
},
},
finished: {
id: 'installation-finished',
entry: [ 'assignInstallationDuration' ],
always: [
{ target: 'reportErrors', cond: 'hasErrors' },
{ target: 'reportSuccess' },
],
},
timedOut: {
entry: [ 'assignInstallationDuration' ],
invoke: {
src: 'queueRemainingPluginsAsync',
onDone: {
target: 'finished',
},
},
},
reportErrors: {
entry: 'updateParentWithInstallationErrors',
},
reportSuccess: {
entry: 'updateParentWithInstallationSuccess',
},
},
},
{
delays: {
INSTALLATION_TIMEOUT: 30000,
},
actions: {
// populateDefaults needed because passing context from the parents overrides the
// full context object of the child. Not needed in xstatev5 because it
// becomes a merge
populateDefaults: assign( {
installedPlugins: [],
errors: [],
startTime: 0,
installationDuration: 0,
} ),
assignPluginsInstallationQueue: assign( {
pluginsInstallationQueue: ( ctx ) => {
return ctx.selectedPlugins;
},
} ),
assignStartTime: assign( {
startTime: () => window.performance.now(),
} ),
assignInstallationDuration: assign( {
installationDuration: ( ctx ) =>
window.performance.now() - ctx.startTime,
} ),
assignInstallationSuccessDetails: assign( {
installedPlugins: ( ctx, evt ) => {
const plugin = ctx.pluginsInstallationQueue[ 0 ];
return [
...ctx.installedPlugins,
{
plugin,
installTime:
(
evt as DoneInvokeEvent< InstallAndActivateSuccessResponse >
).data.data.install_time[ plugin ] || 0,
},
];
},
} ),
assignInstallationErrorDetails: assign( {
errors: ( ctx, evt ) => {
return [
...ctx.errors,
{
plugin: ctx.pluginsInstallationQueue[ 0 ],
error: (
evt as DoneInvokeEvent< InstallAndActivateErrorResponse >
).data.data.message,
},
];
},
} ),
removePluginFromQueue: assign( {
pluginsInstallationQueue: ( ctx ) => {
return ctx.pluginsInstallationQueue.slice( 1 );
},
} ),
updateParentWithPluginProgress: pure( ( ctx ) =>
sendParent(
createPluginInstalledAndActivatedEvent(
ctx.selectedPlugins.length,
ctx.selectedPlugins.length -
ctx.pluginsInstallationQueue.length
)
)
),
updateParentWithInstallationErrors: sendParent( ( ctx ) =>
createInstallationCompletedWithErrorsEvent( ctx.errors )
),
updateParentWithInstallationSuccess: sendParent( ( ctx ) =>
createInstallationCompletedEvent( {
installedPlugins: ctx.installedPlugins,
totalTime: ctx.installationDuration,
} )
),
},
guards: {
hasErrors: ( ctx ) => ctx.errors.length > 0,
hasPluginsToInstall: ( ctx ) =>
ctx.pluginsInstallationQueue.length > 0,
},
services: {
installPlugin: ( ctx ) => {
return dispatch( PLUGINS_STORE_NAME ).installAndActivatePlugins(
[ ctx.pluginsInstallationQueue[ 0 ] ]
);
}
setInstallationCompletedTime();
send(
createInstallationCompletedEvent( installationCompletedResult )
);
};
const handlePluginInstalledAndActivated = (
installedPluginIndex: number
) => {
send(
createPluginInstalledAndActivatedEvent(
context.pluginsSelected.length,
installedPluginIndex + 1
)
);
};
const handlePluginInstallError = ( plugin: string, error: unknown ) => {
errors.push( {
plugin,
error: error instanceof Error ? error.message : String( error ),
} );
};
const handlePluginInstallation = async (
installedPluginIndex: number
) => {
// Set by timer when it's up
if ( ! continueInstallation ) {
return;
}
const plugin = getPluginSlug(
context.pluginsSelected[ installedPluginIndex ]
);
try {
const response = await dispatch(
PLUGINS_STORE_NAME
).installAndActivatePlugins( [ plugin ] );
installationCompletedResult.installedPlugins.push( {
plugin,
installTime: response.data?.install_time?.[ plugin ] || 0,
} );
handlePluginInstalledAndActivated( installedPluginIndex );
} catch ( error ) {
handlePluginInstallError( plugin, error );
}
};
const handleTimerExpired = async () => {
continueInstallation = false;
const remainingPlugins = differenceWith(
context.pluginsSelected,
installationCompletedResult.installedPlugins.map(
( plugin ) => plugin.plugin
)
);
await dispatch( PLUGINS_STORE_NAME ).installPlugins(
remainingPlugins as PluginNames[],
true
);
handleInstallationCompleted();
};
const timer = setTimeout( handleTimerExpired, 1000 * 30 );
for ( let index = 0; index < context.pluginsSelected.length; index++ ) {
await handlePluginInstallation( index );
}
clearTimeout( timer );
handleInstallationCompleted();
};
},
queueRemainingPluginsAsync: ( ctx ) => {
return dispatch( PLUGINS_STORE_NAME ).installPlugins(
ctx.pluginsInstallationQueue,
true
);
},
},
}
);

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { PluginNames } from '@woocommerce/data';
import { interpret } from 'xstate';
/**
* Internal dependencies
*/
import {
pluginInstallerMachine,
InstalledPlugin,
PluginInstallError,
} from '../installAndActivatePlugins';
describe( 'pluginInstallerMachine', () => {
const dispatchInstallPluginMock = jest.fn();
const defaultContext = {
selectedPlugins: [] as PluginNames[],
pluginsInstallationQueue: [] as PluginNames[],
installedPlugins: [] as InstalledPlugin[],
startTime: 0,
installationDuration: 0,
errors: [] as PluginInstallError[],
};
const mockConfig = {
delays: {
INSTALLATION_DELAY: 1000,
},
actions: {
updateParentWithPluginProgress: jest.fn(),
updateParentWithInstallationErrors: jest.fn(),
updateParentWithInstallationSuccess: jest.fn(),
},
services: {
installPlugin: dispatchInstallPluginMock,
queueRemainingPluginsAsync: jest.fn(),
},
};
beforeEach( () => {
jest.resetAllMocks();
} );
it( 'when given one plugin it should call the installPlugin service once', ( done ) => {
const machineUnderTest = pluginInstallerMachine
.withConfig( mockConfig )
.withContext( {
...defaultContext,
selectedPlugins: [ 'woocommerce-payments' ],
} );
dispatchInstallPluginMock.mockImplementationOnce( ( context ) => {
expect( context.pluginsInstallationQueue ).toEqual( [
'woocommerce-payments',
] );
return Promise.resolve( {
data: {
install_time: {
'woocommerce-payments': 1000,
},
},
} );
} );
const service = interpret( machineUnderTest ).start();
service.onTransition( ( state ) => {
if ( state.matches( 'reportSuccess' ) ) {
expect(
mockConfig.actions.updateParentWithInstallationSuccess
).toHaveBeenCalledWith(
// context param
expect.objectContaining( {
installedPlugins: [
{
plugin: 'woocommerce-payments',
installTime: 1000,
},
],
} ),
// event param
expect.any( Object ),
// meta param
expect.any( Object )
);
done();
}
} );
} );
} );

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Refactored Core Profiler's plugin installation step to use XState