Skip to content
1 change: 1 addition & 0 deletions src/__mocks__/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const Menu = {

export const shell = {
openExternal: jest.fn(),
openPath: jest.fn( async () => '' ),
trashItem: jest.fn(),
};

Expand Down
36 changes: 27 additions & 9 deletions src/lib/deeplink/handlers/add-site-with-blueprint.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { app, dialog } from 'electron';
import { app, dialog, shell } from 'electron';
import nodePath from 'path';
import { __ } from '@wordpress/i18n';
import fs from 'fs-extra';
import { validateBlueprintData } from 'common/lib/blueprint-validation';
import { sendIpcEventToRenderer } from 'src/ipc-utils';
import { download } from 'src/lib/download';
import { getLogsFilePath } from 'src/logging';
import { getMainWindow } from 'src/main-window';

function getBlueprintDeeplinkErrorMessage( error: unknown ): string {
const errorMessage = ( error instanceof Error ? error.message : '' ).toLowerCase();

const networkErrors = [ 'enotfound', 'econnrefused', 'etimedout', 'network' ];
if ( networkErrors.some( ( err ) => errorMessage.includes( err ) ) ) {
return __(
'Could not connect to the server. Please check your internet connection and try again.'
);
}

return __( 'Please check the link and try again.' );
}

/**
* Handles the add-site deeplink callback.
* This function is called when a user clicks a deeplink like:
Expand Down Expand Up @@ -77,16 +91,20 @@ export async function handleAddSiteWithBlueprint( urlObject: URL ): Promise< voi
} );
}

const errorDetail =
error instanceof Error
? error.message
: __( 'The Blueprint could not be loaded. Please check the link and try again.' );

await dialog.showMessageBox( mainWindow, {
const response = await dialog.showMessageBox( mainWindow, {
type: 'error',
message: __( 'Failed to load Blueprint' ),
detail: errorDetail,
buttons: [ __( 'OK' ) ],
detail: getBlueprintDeeplinkErrorMessage( error ),
buttons: [ __( 'Open Studio Logs' ), __( 'OK' ) ],
defaultId: 1,
} );

if ( response.response === 0 ) {
const logFilePath = getLogsFilePath();
const err = await shell.openPath( logFilePath );
if ( err ) {
console.error( `Error opening logs file: ${ logFilePath } ${ err }` );
}
}
}
}
86 changes: 61 additions & 25 deletions src/lib/deeplink/tests/add-site.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* @jest-environment node
*/
import { app, dialog, BrowserWindow } from 'electron';
import { app, dialog, shell, BrowserWindow } from 'electron';
import fs from 'fs-extra';
import { validateBlueprintData } from 'common/lib/blueprint-validation';
import { sendIpcEventToRenderer } from 'src/ipc-utils';
Expand All @@ -14,6 +14,9 @@ jest.mock( 'fs-extra' );
jest.mock( 'src/ipc-utils' );
jest.mock( 'src/lib/download' );
jest.mock( 'src/main-window' );
jest.mock( 'src/logging', () => ( {
getLogsFilePath: jest.fn( () => '/mock/path/to/logs.log' ),
} ) );
jest.mock( 'common/lib/blueprint-validation', () => ( {
validateBlueprintData: jest.fn(),
} ) );
Expand All @@ -34,6 +37,16 @@ describe( 'handleAddSiteWithBlueprint', () => {
focus: jest.fn(),
} as unknown as BrowserWindow;

const expectErrorDialog = ( detail: string ) => {
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: 'Failed to load Blueprint',
detail,
buttons: [ 'Open Studio Logs', 'OK' ],
defaultId: 1,
} );
};

const createBlueprintUrl = ( blueprintUrl: string ) => {
const encodedUrl = encodeURIComponent( blueprintUrl );
return new URL( `wp-studio://add-site?blueprint_url=${ encodedUrl }` );
Expand All @@ -46,7 +59,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
jest.mocked( fs.mkdir ).mockImplementation( async () => {} );
jest.mocked( getMainWindow ).mockResolvedValue( mockMainWindow );
jest.mocked( dialog.showMessageBox ).mockResolvedValue( {
response: 0,
response: 1,
checkboxChecked: false,
} );
} );
Expand Down Expand Up @@ -94,12 +107,7 @@ describe( 'handleAddSiteWithBlueprint', () => {

expect( download ).not.toHaveBeenCalled();
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: expect.any( String ),
detail: expect.any( String ),
buttons: expect.any( Array ),
} );
expectErrorDialog( 'Please check the link and try again.' );
} );

it( 'should handle download failure gracefully', async () => {
Expand All @@ -114,12 +122,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
expect( download ).toHaveBeenCalled();
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
expect( fs.remove ).toHaveBeenCalledWith( expect.stringContaining( 'blueprint-' ) );
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: expect.any( String ),
detail: expect.any( String ),
buttons: expect.any( Array ),
} );
expectErrorDialog( 'Please check the link and try again.' );
} );

it( 'should restore and focus window when minimized', async () => {
Expand Down Expand Up @@ -164,12 +167,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
expect( download ).toHaveBeenCalled();
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
expect( fs.remove ).toHaveBeenCalledWith( expect.stringContaining( 'blueprint-' ) );
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: expect.any( String ),
detail: 'Invalid Blueprint format',
buttons: expect.any( Array ),
} );
expectErrorDialog( 'Please check the link and try again.' );
} );

describe( 'base64 blueprint handling', () => {
Expand Down Expand Up @@ -203,12 +201,50 @@ describe( 'handleAddSiteWithBlueprint', () => {
await handleAddSiteWithBlueprint( url );

expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
type: 'error',
message: expect.any( String ),
detail: expect.any( String ),
buttons: expect.any( Array ),
expectErrorDialog( 'Please check the link and try again.' );
} );
} );

describe( 'user-friendly error messages', () => {
it( 'should show user-friendly message for network connectivity errors', async () => {
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );

jest.mocked( download ).mockRejectedValue( new Error( 'getaddrinfo ENOTFOUND example.com' ) );
jest.mocked( fs.remove ).mockImplementation( async () => {} );

await handleAddSiteWithBlueprint( url );

expectErrorDialog(
'Could not connect to the server. Please check your internet connection and try again.'
);
} );

it( 'should show generic error message for other errors', async () => {
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );

jest
.mocked( download )
.mockRejectedValue( new Error( 'Request failed with status code: 500' ) );
jest.mocked( fs.remove ).mockImplementation( async () => {} );

await handleAddSiteWithBlueprint( url );

expectErrorDialog( 'Please check the link and try again.' );
} );

it( 'should open logs file when user clicks Open Studio Logs button', async () => {
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );

jest.mocked( download ).mockRejectedValue( new Error( 'Some error' ) );
jest.mocked( fs.remove ).mockImplementation( async () => {} );
jest.mocked( dialog.showMessageBox ).mockResolvedValue( {
response: 0, // "Open Studio Logs" button
checkboxChecked: false,
} );

await handleAddSiteWithBlueprint( url );

expect( shell.openPath ).toHaveBeenCalledWith( '/mock/path/to/logs.log' );
} );
} );
} );