Created
May 30, 2024 03:24
-
-
Save rjchow/c8397bf05b9a9c77bcee1349bb7ff28f to your computer and use it in GitHub Desktop.
Plugin Installer Machine & Tests
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* External dependencies | |
*/ | |
import { | |
PromiseActorLogic, | |
createActor, | |
fromPromise, | |
waitFor, | |
SimulatedClock, | |
} from 'xstate5'; | |
/** | |
* Internal dependencies | |
*/ | |
import { pluginInstallerMachine } from '../installAndActivatePlugins'; | |
describe( 'pluginInstallerMachine', () => { | |
const mockConfig = { | |
delays: { | |
INSTALLATION_TIMEOUT: 1000, | |
}, | |
actions: { | |
updateParentWithPluginProgress: jest.fn(), | |
updateParentWithInstallationErrors: jest.fn(), | |
updateParentWithInstallationSuccess: jest.fn(), | |
}, | |
actors: { | |
queueRemainingPluginsAsync: fromPromise( | |
jest.fn() | |
) as unknown as PromiseActorLogic< unknown, unknown >, | |
}, | |
}; | |
beforeEach( () => { | |
jest.resetAllMocks(); | |
} ); | |
it( 'when given one plugin it should call the installPlugin service once', async () => { | |
const mockInstallPlugin = jest.fn(); | |
mockInstallPlugin.mockResolvedValueOnce( { | |
data: { | |
install_time: { | |
'woocommerce-payments': 1000, | |
}, | |
}, | |
} ); | |
const mockActors = { | |
installPlugin: fromPromise( mockInstallPlugin ), | |
}; | |
const machineUnderTest = pluginInstallerMachine.provide( { | |
...mockConfig, | |
actors: mockActors, | |
} ); | |
const service = createActor( machineUnderTest, { | |
input: { | |
selectedPlugins: [ 'woocommerce-payments' ], | |
pluginsAvailable: [], | |
}, | |
} ).start(); | |
await waitFor( service, ( snap ) => snap.matches( 'reportSuccess' ) ); | |
expect( | |
mockConfig.actions.updateParentWithInstallationSuccess.mock | |
.calls[ 0 ][ 0 ] | |
).toMatchObject( { | |
context: { | |
installedPlugins: [ | |
{ | |
plugin: 'woocommerce-payments', | |
installTime: 1000, | |
}, | |
], | |
}, | |
} ); | |
expect( mockInstallPlugin ).toHaveBeenCalledTimes( 1 ); | |
expect( | |
mockConfig.actions.updateParentWithPluginProgress | |
).toHaveBeenCalledTimes( 1 ); | |
} ); | |
it( 'when given multiple plugins it should call the installPlugin service the equivalent number of times', async () => { | |
const mockInstallPlugin = jest.fn(); | |
mockInstallPlugin | |
.mockResolvedValueOnce( { | |
data: { | |
install_time: { | |
'woocommerce-payments': 1000, | |
}, | |
}, | |
} ) | |
.mockResolvedValueOnce( { | |
data: { | |
install_time: { | |
jetpack: 1000, | |
}, | |
}, | |
} ); | |
const mockActors = { | |
installPlugin: fromPromise( mockInstallPlugin ), | |
}; | |
const machineUnderTest = pluginInstallerMachine.provide( { | |
...mockConfig, | |
actors: mockActors, | |
} ); | |
const service = createActor( machineUnderTest, { | |
input: { | |
selectedPlugins: [ 'woocommerce-payments', 'jetpack' ], | |
pluginsAvailable: [], | |
}, | |
} ).start(); | |
await waitFor( service, ( snap ) => snap.matches( 'reportSuccess' ) ); | |
expect( | |
mockConfig.actions.updateParentWithInstallationSuccess.mock | |
.calls[ 0 ][ 0 ] | |
).toMatchObject( { | |
context: { | |
installedPlugins: [ | |
{ | |
plugin: 'woocommerce-payments', | |
installTime: 1000, | |
}, | |
{ | |
plugin: 'jetpack', | |
installTime: 1000, | |
}, | |
], | |
}, | |
} ); | |
expect( mockInstallPlugin ).toHaveBeenCalledTimes( 2 ); | |
expect( | |
mockConfig.actions.updateParentWithPluginProgress | |
).toHaveBeenCalledTimes( 2 ); | |
} ); | |
it( 'when a plugin install errors it should report it accordingly', async () => { | |
const mockInstallPlugin = jest.fn(); | |
mockInstallPlugin | |
.mockResolvedValueOnce( { | |
data: { | |
install_time: { | |
'woocommerce-payments': 1000, | |
}, | |
}, | |
} ) | |
.mockRejectedValueOnce( { | |
message: 'error message installing jetpack', | |
} ); | |
const mockActors = { | |
installPlugin: fromPromise( mockInstallPlugin ), | |
}; | |
const machineUnderTest = pluginInstallerMachine.provide( { | |
...mockConfig, | |
actors: mockActors, | |
} ); | |
const service = createActor( machineUnderTest, { | |
input: { | |
selectedPlugins: [ 'woocommerce-payments', 'jetpack' ], | |
pluginsAvailable: [], | |
}, | |
} ).start(); | |
await waitFor( service, ( snap ) => snap.matches( 'reportErrors' ) ); | |
expect( | |
mockConfig.actions.updateParentWithInstallationErrors.mock | |
.calls[ 0 ][ 0 ] | |
).toMatchObject( { | |
context: { | |
installedPlugins: [ | |
{ | |
plugin: 'woocommerce-payments', | |
installTime: 1000, | |
}, | |
], | |
errors: [ | |
{ | |
error: 'error message installing jetpack', | |
plugin: 'jetpack', | |
}, | |
], | |
}, | |
} ); | |
expect( mockInstallPlugin ).toHaveBeenCalledTimes( 2 ); | |
expect( | |
mockConfig.actions.updateParentWithPluginProgress | |
).toHaveBeenCalledTimes( 2 ); | |
} ); | |
it( 'when plugins take longer to install than the timeout, it should queue them async', async () => { | |
const clock = new SimulatedClock(); | |
const mockInstallPlugin = jest.fn(); | |
mockInstallPlugin | |
.mockResolvedValueOnce( { | |
data: { | |
install_time: { | |
'woocommerce-payments': 1000, | |
}, | |
}, | |
} ) | |
.mockImplementationOnce( async () => { | |
clock.increment( 1500 ); // simulate time passed by 1500ms before this call returns | |
return { | |
data: { | |
install_time: { | |
jetpack: 1500, | |
}, | |
}, | |
}; | |
} ); | |
const mockInstallPluginAsync = jest.fn(); | |
mockInstallPluginAsync.mockResolvedValueOnce( { | |
data: { | |
job_id: 'foo', | |
status: 'pending', | |
plugins: [ { status: 'pending', errors: [] } ], | |
}, | |
} ); | |
const mockActors = { | |
installPlugin: fromPromise( mockInstallPlugin ), | |
queueRemainingPluginsAsync: fromPromise( mockInstallPluginAsync ), | |
}; | |
const machineUnderTest = pluginInstallerMachine.provide( { | |
...mockConfig, | |
actors: mockActors, | |
} ); | |
const service = createActor( machineUnderTest, { | |
input: { | |
selectedPlugins: [ | |
'woocommerce-payments', | |
'jetpack', | |
'woocommerce-services', | |
], | |
pluginsAvailable: [], | |
}, | |
clock, | |
} ).start(); | |
await waitFor( service, ( snap ) => snap.matches( 'reportSuccess' ) ); | |
expect( | |
mockConfig.actions.updateParentWithInstallationSuccess.mock | |
.calls[ 0 ][ 0 ] | |
).toMatchObject( { | |
context: { | |
installedPlugins: [ | |
{ | |
plugin: 'woocommerce-payments', | |
installTime: 1000, | |
}, | |
], | |
}, | |
} ); | |
expect( mockInstallPlugin ).toHaveBeenCalledTimes( 2 ); | |
expect( | |
mockInstallPluginAsync.mock.calls[ 0 ][ 0 ].input | |
.pluginsInstallationQueue | |
).toEqual( [ 'jetpack', 'woocommerce-services' ] ); | |
expect( mockInstallPluginAsync ).toHaveBeenCalledTimes( 1 ); | |
expect( | |
mockInstallPluginAsync.mock.calls[ 0 ][ 0 ].input | |
.pluginsInstallationQueue | |
).toEqual( [ 'jetpack', 'woocommerce-services' ] ); | |
expect( | |
mockConfig.actions.updateParentWithPluginProgress | |
).toHaveBeenCalledTimes( 1 ); | |
expect( | |
mockConfig.actions.updateParentWithPluginProgress.mock | |
.calls[ 0 ][ 0 ] | |
).toMatchObject( { | |
context: { | |
installedPlugins: [ | |
{ | |
plugin: 'woocommerce-payments', | |
installTime: 1000, | |
}, | |
], | |
}, | |
} ); | |
} ); | |
} ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const pluginInstallerMachine = createMachine( | |
{ | |
id: 'plugin-installer', | |
initial: 'installing', | |
types: {} as { | |
context: PluginInstallerMachineContext; | |
}, | |
context: ( { | |
input, | |
}: { | |
input: Pick< | |
PluginInstallerMachineContext, | |
'selectedPlugins' | 'pluginsAvailable' | |
>; | |
} ) => { | |
return { | |
selectedPlugins: | |
input?.selectedPlugins || ( [] as PluginNames[] ), | |
pluginsAvailable: | |
input?.pluginsAvailable || | |
( [] as ExtensionList[ 'plugins' ] | [] ), | |
pluginsInstallationQueue: [] as PluginNames[], | |
installedPlugins: [] as InstalledPlugin[], | |
startTime: 0, | |
installationDuration: 0, | |
errors: [] as PluginInstallError[], | |
} as PluginInstallerMachineContext; | |
}, | |
states: { | |
installing: { | |
initial: 'installer', | |
entry: [ 'assignPluginsInstallationQueue', 'assignStartTime' ], | |
after: { | |
INSTALLATION_TIMEOUT: 'timedOut', | |
}, | |
states: { | |
installer: { | |
initial: 'installing', | |
states: { | |
installing: { | |
invoke: { | |
systemId: 'installPlugin', | |
src: 'installPlugin', | |
input: ( { context } ) => context, | |
onDone: { | |
actions: [ | |
'assignInstallationSuccessDetails', | |
], | |
target: 'removeFromQueue', | |
}, | |
onError: { | |
actions: | |
'assignInstallationErrorDetails', | |
target: 'removeFromQueue', | |
}, | |
}, | |
}, | |
removeFromQueue: { | |
entry: [ | |
'removePluginFromQueue', | |
'updateParentWithPluginProgress', | |
], | |
always: [ | |
{ | |
target: 'installing', | |
guard: 'hasPluginsToInstall', | |
}, | |
{ target: '#installation-finished' }, | |
], | |
}, | |
}, | |
}, | |
}, | |
}, | |
finished: { | |
id: 'installation-finished', | |
entry: [ 'assignInstallationDuration' ], | |
always: [ | |
{ target: 'reportErrors', guard: 'hasErrors' }, | |
{ target: 'reportSuccess' }, | |
], | |
}, | |
timedOut: { | |
entry: [ 'assignInstallationDuration' ], | |
invoke: { | |
systemId: 'queueRemainingPluginsAsync', | |
src: 'queueRemainingPluginsAsync', | |
input: ( { context } ) => ( { | |
pluginsInstallationQueue: | |
context.pluginsInstallationQueue, | |
} ), | |
onDone: { | |
target: 'reportSuccess', | |
}, | |
}, | |
}, | |
reportErrors: { | |
entry: 'updateParentWithInstallationErrors', | |
}, | |
reportSuccess: { | |
entry: 'updateParentWithInstallationSuccess', | |
}, | |
}, | |
}, | |
{ | |
delays: { | |
INSTALLATION_TIMEOUT: 30000, | |
}, | |
actions: { | |
assignPluginsInstallationQueue: assign( { | |
pluginsInstallationQueue: ( { context } ) => { | |
// Sort the plugins by install_priority so that the smaller plugins are installed first | |
// install_priority is set by plugin's size | |
// Lower install_prioirty means the plugin is smaller | |
return context.selectedPlugins.slice().sort( ( a, b ) => { | |
const aIndex = context.pluginsAvailable.find( | |
( plugin ) => plugin.key === a | |
); | |
const bIndex = context.pluginsAvailable.find( | |
( plugin ) => plugin.key === b | |
); | |
return ( | |
( aIndex?.install_priority ?? 99 ) - | |
( bIndex?.install_priority ?? 99 ) | |
); | |
} ); | |
}, | |
} ), | |
assignStartTime: assign( { | |
startTime: () => window.performance.now(), | |
} ), | |
assignInstallationDuration: assign( { | |
installationDuration: ( { context } ) => | |
window.performance.now() - context.startTime, | |
} ), | |
assignInstallationSuccessDetails: assign( { | |
installedPlugins: ( { context, event } ) => { | |
const plugin = context.pluginsInstallationQueue[ 0 ]; | |
return [ | |
...context.installedPlugins, | |
{ | |
plugin, | |
installTime: | |
( | |
event as DoneActorEvent< InstallAndActivateSuccessResponse > | |
).output.data.install_time[ plugin ] || 0, | |
}, | |
]; | |
}, | |
} ), | |
assignInstallationErrorDetails: assign( { | |
errors: ( { context, event } ) => { | |
return [ | |
...context.errors, | |
{ | |
plugin: context.pluginsInstallationQueue[ 0 ], | |
error: ( | |
event as ErrorActorEvent< InstallAndActivateErrorResponse > | |
).error.message, | |
}, | |
]; | |
}, | |
} ), | |
removePluginFromQueue: assign( { | |
pluginsInstallationQueue: ( { context } ) => { | |
return context.pluginsInstallationQueue.slice( 1 ); | |
}, | |
} ), | |
updateParentWithPluginProgress: sendParent( ( { context } ) => | |
createPluginInstalledAndActivatedEvent( | |
context.selectedPlugins.length, | |
context.selectedPlugins.length - | |
context.pluginsInstallationQueue.length | |
) | |
), | |
updateParentWithInstallationErrors: sendParent( ( { context } ) => | |
createInstallationCompletedWithErrorsEvent( context.errors ) | |
), | |
updateParentWithInstallationSuccess: sendParent( ( { context } ) => | |
createInstallationCompletedEvent( { | |
installedPlugins: context.installedPlugins, | |
totalTime: context.installationDuration, | |
} ) | |
), | |
}, | |
guards: { | |
hasErrors: ( { context } ) => context.errors.length > 0, | |
hasPluginsToInstall: ( { context } ) => | |
context.pluginsInstallationQueue.length > 0, | |
}, | |
actors: { | |
installPlugin: fromPromise( | |
async ( { | |
input: { pluginsInstallationQueue }, | |
}: { | |
input: { pluginsInstallationQueue: PluginNames[] }; | |
} ) => { | |
return dispatch( | |
PLUGINS_STORE_NAME | |
).installAndActivatePlugins( [ | |
pluginsInstallationQueue[ 0 ], | |
] ); | |
} | |
), | |
queueRemainingPluginsAsync: fromPromise( | |
async ( { | |
input: { pluginsInstallationQueue }, | |
}: { | |
input: { pluginsInstallationQueue: PluginNames[] }; | |
} ) => { | |
return dispatch( | |
ONBOARDING_STORE_NAME | |
).installAndActivatePluginsAsync( | |
pluginsInstallationQueue | |
); | |
} | |
), | |
}, | |
} | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment