Skip to content

Commit 596772d

Browse files
Merge ff51c46 into 6592b0b
2 parents 6592b0b + ff51c46 commit 596772d

File tree

6 files changed

+132
-97
lines changed

6 files changed

+132
-97
lines changed

src/ipc-handlers.ts

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import https from 'node:https';
1818
import nodePath from 'path';
1919
import * as Sentry from '@sentry/electron/main';
2020
import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n';
21-
import { compileBlueprint } from '@wp-playground/blueprints';
2221
import archiver from 'archiver';
2322
import { z } from 'zod';
2423
import {
@@ -39,7 +38,7 @@ import { ARCHIVER_OPTIONS, DEFAULT_TERMINAL, MAIN_MIN_WIDTH, SIDEBAR_WIDTH } fro
3938
import { sendIpcEventToRenderer, sendIpcEventToRendererWithWindow } from 'src/ipc-utils';
4039
import { ACTIVE_SYNC_OPERATIONS } from 'src/lib/active-sync-operations';
4140
import { getBetaFeatures as getBetaFeaturesFromLib } from 'src/lib/beta-features';
42-
import { scanBlueprintForUnsupportedFeatures } from 'src/lib/blueprint-features';
41+
import { validateBlueprintData } from 'src/lib/blueprint-features';
4342
import { bumpStat } from 'src/lib/bump-stats';
4443
import { getImporterMetric, getBlueprintMetric } from 'src/lib/bump-stats/lib';
4544
import {
@@ -1846,27 +1845,7 @@ export async function validateBlueprint(
18461845
error?: string;
18471846
warnings?: Array< { feature: string; reason: string; alternative?: string } >;
18481847
} > {
1849-
try {
1850-
await compileBlueprint( blueprintJson );
1851-
} catch ( error ) {
1852-
const errorMessage = error instanceof Error ? error.message : __( 'Invalid Blueprint format' );
1853-
return {
1854-
valid: false,
1855-
error: errorMessage,
1856-
};
1857-
}
1858-
1859-
const unsupportedFeatures = scanBlueprintForUnsupportedFeatures( blueprintJson );
1860-
1861-
const warnings = unsupportedFeatures.map( ( feature ) => ( {
1862-
feature: feature.name,
1863-
reason: feature.reason,
1864-
} ) );
1865-
1866-
return {
1867-
valid: true,
1868-
warnings: warnings.length > 0 ? warnings : undefined,
1869-
};
1848+
return validateBlueprintData( blueprintJson );
18701849
}
18711850

18721851
export async function readBlueprintFile(

src/lib/blueprint-features.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { __ } from '@wordpress/i18n';
2+
import { compileBlueprint } from '@wp-playground/blueprints';
23
import { Blueprint } from 'src/stores/wpcom-api';
34

45
interface UnsupportedFeature {
@@ -113,3 +114,38 @@ export function filterUnsupportedBlueprintFeatures(
113114

114115
return filtered;
115116
}
117+
118+
export interface BlueprintValidationResult {
119+
valid: boolean;
120+
error?: string;
121+
warnings?: Array< { feature: string; reason: string; alternative?: string } >;
122+
}
123+
124+
/**
125+
* Validates a blueprint by compiling it and scanning for unsupported features.
126+
*/
127+
export async function validateBlueprintData(
128+
blueprintJson: Blueprint[ 'blueprint' ]
129+
): Promise< BlueprintValidationResult > {
130+
try {
131+
await compileBlueprint( blueprintJson );
132+
} catch ( error ) {
133+
const errorMessage = error instanceof Error ? error.message : __( 'Invalid Blueprint format' );
134+
return {
135+
valid: false,
136+
error: errorMessage,
137+
};
138+
}
139+
140+
const unsupportedFeatures = scanBlueprintForUnsupportedFeatures( blueprintJson );
141+
142+
const warnings = unsupportedFeatures.map( ( feature ) => ( {
143+
feature: feature.name,
144+
reason: feature.reason,
145+
} ) );
146+
147+
return {
148+
valid: true,
149+
warnings: warnings.length > 0 ? warnings : undefined,
150+
};
151+
}

src/lib/deeplink/handlers/add-site-blueprint-with-url.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import * as Sentry from '@sentry/electron/main';
44
import { __ } from '@wordpress/i18n';
55
import fs from 'fs-extra';
66
import { sendIpcEventToRenderer } from 'src/ipc-utils';
7+
import { validateBlueprintData } from 'src/lib/blueprint-features';
78
import { download } from 'src/lib/download';
89
import { getMainWindow } from 'src/main-window';
910

1011
/**
1112
* Handles the add-site deeplink callback.
1213
* This function is called when a user clicks a deeplink like:
13-
* - wpcom-local-dev://add-site?blueprint_url=<encoded-url>
14-
* - wpcom-local-dev://add-site?blueprint=<base64-encoded-json>
14+
* - wp-studio://add-site?blueprint_url=<encoded-url>
15+
* - wp-studio://add-site?blueprint=<base64-encoded-json>
1516
*
1617
* It either downloads the blueprint from the URL or decodes the base64 blueprint,
1718
* and opens the Add Site modal with the blueprint pre-filled.
@@ -71,6 +72,24 @@ export async function handleAddSiteWithBlueprint( urlObject: URL ): Promise< voi
7172

7273
try {
7374
await download( decodedUrl, blueprintPath, false, 'blueprint' );
75+
76+
const blueprintJson = await fs.readJson( blueprintPath );
77+
const validation = await validateBlueprintData( blueprintJson );
78+
79+
if ( ! validation.valid ) {
80+
await fs.remove( blueprintPath ).catch( () => {
81+
// Ignore cleanup errors
82+
} );
83+
84+
await dialog.showMessageBox( mainWindow, {
85+
type: 'error',
86+
message: __( 'Blueprint validation failed' ),
87+
detail: validation.error || __( 'Invalid Blueprint format' ),
88+
buttons: [ __( 'OK' ) ],
89+
} );
90+
return;
91+
}
92+
7493
await sendIpcEventToRenderer( 'add-site-blueprint-from-url', { blueprintPath } );
7594
} catch ( error ) {
7695
console.error( 'Failed to download blueprint from deeplink:', error );

src/lib/deeplink/tests/add-site.test.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { app, dialog, BrowserWindow } from 'electron';
55
import fs from 'fs-extra';
66
import { sendIpcEventToRenderer } from 'src/ipc-utils';
7+
import { validateBlueprintData } from 'src/lib/blueprint-features';
78
import { handleAddSiteWithBlueprint } from 'src/lib/deeplink/handlers/add-site-blueprint-with-url';
89
import { download } from 'src/lib/download';
910
import { getMainWindow } from 'src/main-window';
@@ -13,6 +14,9 @@ jest.mock( 'fs-extra' );
1314
jest.mock( 'src/ipc-utils' );
1415
jest.mock( 'src/lib/download' );
1516
jest.mock( 'src/main-window' );
17+
jest.mock( 'src/lib/blueprint-features', () => ( {
18+
validateBlueprintData: jest.fn(),
19+
} ) );
1620

1721
// Silence console.error output
1822
beforeAll( () => {
@@ -30,8 +34,14 @@ describe( 'handleAddSiteWithBlueprint', () => {
3034
focus: jest.fn(),
3135
} as unknown as BrowserWindow;
3236

37+
const createBlueprintUrl = ( blueprintUrl: string ) => {
38+
const encodedUrl = encodeURIComponent( blueprintUrl );
39+
return new URL( `wp-studio://add-site?blueprint_url=${ encodedUrl }` );
40+
};
41+
3342
beforeEach( () => {
3443
jest.clearAllMocks();
44+
( mockMainWindow.isMinimized as jest.Mock ).mockReturnValue( false );
3545
jest.mocked( app.getPath ).mockReturnValue( '/tmp' );
3646
( fs.mkdir as unknown as jest.Mock ).mockResolvedValue( undefined );
3747
jest.mocked( getMainWindow ).mockResolvedValue( mockMainWindow );
@@ -43,10 +53,11 @@ describe( 'handleAddSiteWithBlueprint', () => {
4353

4454
it( 'should handle add-site with valid blueprint_url', async () => {
4555
const blueprintUrl = 'https://example.com/blueprint.json';
46-
const encodedUrl = encodeURIComponent( blueprintUrl );
47-
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
56+
const url = createBlueprintUrl( blueprintUrl );
4857

4958
jest.mocked( download ).mockResolvedValue( undefined );
59+
jest.mocked( fs.readJson ).mockResolvedValue( { steps: [] } );
60+
jest.mocked( validateBlueprintData ).mockResolvedValue( { valid: true } );
5061

5162
await handleAddSiteWithBlueprint( url );
5263

@@ -63,7 +74,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
6374
} );
6475

6576
it( 'should not send event if blueprint_url parameter is missing', async () => {
66-
const url = new URL( 'wpcom-local-dev://add-site' );
77+
const url = new URL( 'wp-studio://add-site' );
6778

6879
await handleAddSiteWithBlueprint( url );
6980

@@ -74,7 +85,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
7485
it( 'should handle invalid blueprint_url gracefully', async () => {
7586
const invalidUrl = 'not-a-valid-url';
7687
const encodedUrl = encodeURIComponent( invalidUrl );
77-
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
88+
const url = new URL( `wp-studio://add-site?blueprint_url=${ encodedUrl }` );
7889

7990
await handleAddSiteWithBlueprint( url );
8091

@@ -84,9 +95,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
8495
} );
8596

8697
it( 'should handle download failure gracefully', async () => {
87-
const blueprintUrl = 'https://example.com/blueprint.json';
88-
const encodedUrl = encodeURIComponent( blueprintUrl );
89-
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
98+
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );
9099

91100
const downloadError = new Error( 'Download failed' );
92101
jest.mocked( download ).mockRejectedValue( downloadError );
@@ -106,12 +115,12 @@ describe( 'handleAddSiteWithBlueprint', () => {
106115
} );
107116

108117
it( 'should restore and focus window when minimized', async () => {
109-
const blueprintUrl = 'https://example.com/blueprint.json';
110-
const encodedUrl = encodeURIComponent( blueprintUrl );
111-
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
118+
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );
112119

113120
( mockMainWindow.isMinimized as jest.Mock ).mockReturnValue( true );
114121
jest.mocked( download ).mockResolvedValue( undefined );
122+
( fs.readJson as unknown as jest.Mock ).mockResolvedValue( { steps: [] } );
123+
jest.mocked( validateBlueprintData ).mockResolvedValue( { valid: true } );
115124

116125
await handleAddSiteWithBlueprint( url );
117126

@@ -120,9 +129,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
120129
} );
121130

122131
it( 'should handle cleanup errors gracefully on download failure', async () => {
123-
const blueprintUrl = 'https://example.com/blueprint.json';
124-
const encodedUrl = encodeURIComponent( blueprintUrl );
125-
const url = new URL( `wpcom-local-dev://add-site?blueprint_url=${ encodedUrl }` );
132+
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );
126133

127134
const downloadError = new Error( 'Download failed' );
128135
jest.mocked( download ).mockRejectedValue( downloadError );
@@ -133,6 +140,30 @@ describe( 'handleAddSiteWithBlueprint', () => {
133140
expect( dialog.showMessageBox ).toHaveBeenCalled();
134141
} );
135142

143+
it( 'should handle invalid blueprint and show error dialog', async () => {
144+
const url = createBlueprintUrl( 'https://example.com/blueprint.json' );
145+
146+
jest.mocked( download ).mockResolvedValue( undefined );
147+
( fs.readJson as unknown as jest.Mock ).mockResolvedValue( { invalid: 'data' } );
148+
jest.mocked( validateBlueprintData ).mockResolvedValue( {
149+
valid: false,
150+
error: 'Invalid blueprint format',
151+
} );
152+
( fs.remove as unknown as jest.Mock ).mockResolvedValue( undefined );
153+
154+
await handleAddSiteWithBlueprint( url );
155+
156+
expect( download ).toHaveBeenCalled();
157+
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();
158+
expect( fs.remove ).toHaveBeenCalledWith( expect.stringContaining( 'blueprint-' ) );
159+
expect( dialog.showMessageBox ).toHaveBeenCalledWith( mockMainWindow, {
160+
type: 'error',
161+
message: expect.any( String ),
162+
detail: 'Invalid blueprint format',
163+
buttons: expect.any( Array ),
164+
} );
165+
} );
166+
136167
describe( 'base64 blueprint handling', () => {
137168
it( 'should handle add-site with valid base64-encoded blueprint', async () => {
138169
const blueprintData = {
@@ -141,7 +172,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
141172
};
142173
const blueprintJson = JSON.stringify( blueprintData );
143174
const blueprintBase64 = Buffer.from( blueprintJson ).toString( 'base64' );
144-
const url = new URL( `wpcom-local-dev://add-site?blueprint=${ blueprintBase64 }` );
175+
const url = new URL( `wp-studio://add-site?blueprint=${ blueprintBase64 }` );
145176

146177
await handleAddSiteWithBlueprint( url );
147178

@@ -152,7 +183,7 @@ describe( 'handleAddSiteWithBlueprint', () => {
152183
} );
153184

154185
it( 'should handle invalid base64-encoded blueprint and display error message', async () => {
155-
const url = new URL( 'wpcom-local-dev://add-site?blueprint=invalid-base64!!!' );
186+
const url = new URL( 'wp-studio://add-site?blueprint=invalid-base64!!!' );
156187
await handleAddSiteWithBlueprint( url );
157188

158189
expect( sendIpcEventToRenderer ).not.toHaveBeenCalled();

0 commit comments

Comments
 (0)