Skip to content

Instantly share code, notes, and snippets.

@rjchow
Created May 30, 2024 03:24
Show Gist options
  • Save rjchow/c8397bf05b9a9c77bcee1349bb7ff28f to your computer and use it in GitHub Desktop.
Save rjchow/c8397bf05b9a9c77bcee1349bb7ff28f to your computer and use it in GitHub Desktop.
Plugin Installer Machine & Tests
/**
* 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,
},
],
},
} );
} );
} );
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