Skip to content
32 changes: 21 additions & 11 deletions apps/local/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
recursiveCopyDirectory,
} from '@studio/common/lib/fs-utils';
import { generateNumberedName, generateSiteName } from '@studio/common/lib/generate-site-name';
import { importIpcEventSchema } from '@studio/common/lib/import-export-events';
import { isErrnoException } from '@studio/common/lib/is-errno-exception';
import { getAuthenticationUrl } from '@studio/common/lib/oauth';
import { decodePassword } from '@studio/common/lib/passwords';
Expand Down Expand Up @@ -181,9 +182,7 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
const cliRunner = createCliRunner( { cliBinary, nodeBinary } );
const execute = cliRunner.executeCliCommand;

// --- Server-Sent Events: one stream carries every run's output ------------
// The web connector expects the same envelope on both channels: agent run
// events on `agent`, session-placement updates on `placement`.
// --- Server-Sent Events: one stream carries live UI updates ----------------
const sseClients = new Set< Response >();
function sseSend( message: { channel: string; payload: unknown } ): void {
if ( sseClients.size === 0 ) {
Expand Down Expand Up @@ -812,9 +811,8 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
} )
);

// Import a backup archive into an already-created site — the same CLI `import`
// the desktop forks. The archive path is normally an upload from `/uploads`,
// which is cleaned up afterwards.
// Import a backup into an already-created site. Uploaded files are cleaned up
// after each attempt, so a retry must upload the selected File again.
api.post(
'/sites/:id/import',
asyncHandler( async ( req: Request, res: Response ) => {
Expand All @@ -830,8 +828,18 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
}
try {
await new Promise< void >( ( resolve, reject ) => {
const [ emitter ] = execute( [ 'import', '--path', site.path, archivePath ], {
output: 'capture',
const [ emitter ] = execute(
[ 'import', '--path', site.path, archivePath, '--start-server' ],
{ output: 'capture' }
);
emitter.on( 'data', ( { data } ) => {
const parsed = importIpcEventSchema.safeParse( data );
if ( parsed.success ) {
sseSend( {
channel: 'import',
payload: { siteId: site.id, event: parsed.data.event },
} );
}
} );
emitter.on( 'success', () => resolve() );
emitter.on( 'failure', ( { error } ) => reject( error ) );
Expand All @@ -842,12 +850,14 @@ export async function startLocalServer( options: LocalServerOptions ): Promise<
// direct child of the OS temp dir. Resolve first so `..` in the
// supplied path can't escape the temp root and delete elsewhere.
const uploadDir = path.dirname( path.resolve( archivePath ) );
if ( path.dirname( uploadDir ) === path.resolve( os.tmpdir() ) ) {
if (
path.dirname( uploadDir ) === path.resolve( os.tmpdir() ) &&
path.basename( uploadDir ).startsWith( 'studio-upload-' )
) {
rm( uploadDir, { recursive: true, force: true }, () => undefined );
}
}
const imported = ( await listSites( execute ) ).find( ( s ) => s.id === req.params.id );
res.json( toSiteDetails( imported ?? site ) );
res.status( 204 ).end();
} )
);

Expand Down
4 changes: 2 additions & 2 deletions apps/studio/e2e/page-objects/add-site-modal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Page } from '@playwright/test';
import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';
import { ACCEPTED_ADD_SITE_FILE_TYPES } from '@studio/common/constants';
import { type SiteRuntime } from '@studio/common/lib/site-runtime';
import SiteForm from './site-form';

Expand Down Expand Up @@ -33,7 +33,7 @@ export default class AddSiteModal {
}

get backupFileInput() {
const fileTypes = ACCEPTED_IMPORT_FILE_TYPES.join( ',' );
const fileTypes = ACCEPTED_ADD_SITE_FILE_TYPES.join( ',' );
return this.page.locator( `input[type="file"][accept="${ fileTypes }"]` );
}

Expand Down
9 changes: 3 additions & 6 deletions apps/studio/src/components/content-tab-import-export.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';
import { isSupportedBackupFilename } from '@studio/common/lib/backup-files';
import { speak } from '@wordpress/a11y';
import { Notice } from '@wordpress/components';
import { createInterpolateElement } from '@wordpress/element';
Expand Down Expand Up @@ -161,11 +162,7 @@ const InitialImportButton = ( {
};

const isValidImportFile = ( file: File ): boolean => {
const fileName = file.name.toLowerCase();
return (
ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => fileName.endsWith( ext ) ) ||
fileName.endsWith( '.sql' )
);
return isSupportedBackupFilename( file.name );
};

const ImportSite = ( {
Expand Down Expand Up @@ -329,7 +326,7 @@ const ImportSite = ( {
className="hidden"
type="file"
data-testid="backup-file"
accept={ `${ ACCEPTED_IMPORT_FILE_TYPES.join( ',' ) },.sql` }
accept={ ACCEPTED_IMPORT_FILE_TYPES.join( ',' ) }
onChange={ onFileSelected }
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe( 'ContentTabImportExport Import', () => {
} );

const dropZone = screen.getByText( /Drag a file here, or click to select a file/i );
const file = new File( [ 'file contents' ], 'backup.zip', { type: 'application/zip' } );
const file = new File( [ 'file contents' ], 'backup.sql', { type: 'application/sql' } );

fireEvent.dragEnter( dropZone );
fireEvent.dragOver( dropZone );
Expand All @@ -120,7 +120,7 @@ describe( 'ContentTabImportExport Import', () => {
const fileInput = screen.getByTestId( 'backup-file' );
expect( fileInput ).toBeInTheDocument();

const file = new File( [ 'file contents' ], 'backup.zip', { type: 'application/zip' } );
const file = new File( [ 'file contents' ], 'backup.sql', { type: 'application/sql' } );

await userEvent.upload( fileInput, file );

Expand Down
38 changes: 22 additions & 16 deletions apps/studio/src/modules/add-site/components/import-backup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';
import { ACCEPTED_ADD_SITE_FILE_TYPES } from '@studio/common/constants';
import { isSupportedBackupFilename } from '@studio/common/lib/backup-files';
import {
__experimentalVStack as VStack,
__experimentalHStack as HStack,
Expand Down Expand Up @@ -45,7 +46,7 @@ interface ImportBackupProps {
}

const isValidBackupFile = ( file: File ): boolean => {
return ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) => file.name.toLowerCase().endsWith( ext ) );
return isSupportedBackupFilename( file.name, ACCEPTED_ADD_SITE_FILE_TYPES );
};

export default function ImportBackup( {
Expand All @@ -66,6 +67,20 @@ export default function ImportBackup( {
},
[ onFileSelect ]
);
const handleFile = useCallback(
( file: File ) => {
if ( isValidBackupFile( file ) ) {
handleFileSelection( file );
return;
}
setFileError(
__(
'This file type is not supported. Please use a .zip, .gz, .gzip, .tar, .tar.gz, .wpress, or .xml file.'
)
);
},
[ handleFileSelection, __ ]
);

const handleDragOver = useCallback( ( e: React.DragEvent ) => {
e.preventDefault();
Expand All @@ -91,28 +106,19 @@ export default function ImportBackup( {
return;
}

const file = files[ 0 ];
if ( isValidBackupFile( file ) ) {
handleFileSelection( file );
} else {
setFileError(
__(
'This file type is not supported. Please use a .zip, .gz, .tar, .tar.gz, or .wpress file.'
)
);
}
handleFile( files[ 0 ] );
},
[ handleFileSelection, __ ]
[ handleFile ]
);

const handleFileInputChange = useCallback(
( e: React.ChangeEvent< HTMLInputElement > ) => {
const file = e.target.files?.[ 0 ];
if ( file ) {
handleFileSelection( file );
handleFile( file );
}
},
[ handleFileSelection ]
[ handleFile ]
);

const handleClick = useCallback( () => {
Expand Down Expand Up @@ -209,7 +215,7 @@ export default function ImportBackup( {
<input
ref={ fileInputRef }
type="file"
accept={ ACCEPTED_IMPORT_FILE_TYPES.join( ',' ) }
accept={ ACCEPTED_ADD_SITE_FILE_TYPES.join( ',' ) }
onChange={ handleFileInputChange }
className="hidden"
aria-label={ __( 'Select backup file' ) }
Expand Down
12 changes: 5 additions & 7 deletions apps/studio/src/modules/add-site/components/options.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ACCEPTED_IMPORT_FILE_TYPES } from '@studio/common/constants';
import { ACCEPTED_ADD_SITE_FILE_TYPES } from '@studio/common/constants';
import { isSupportedBackupFilename } from '@studio/common/lib/backup-files';
import {
__experimentalVStack as VStack,
__experimentalHeading as Heading,
Expand Down Expand Up @@ -73,10 +74,7 @@ function ImportDropZone( { onValidated }: { onValidated: ( file: File ) => void
if ( ! file ) {
return;
}
const hasValidExtension = ACCEPTED_IMPORT_FILE_TYPES.some( ( ext ) =>
file.name.toLowerCase().endsWith( ext )
);
if ( ! hasValidExtension ) {
if ( ! isSupportedBackupFilename( file.name, ACCEPTED_ADD_SITE_FILE_TYPES ) ) {
setExtensionError( __( 'Unsupported file type.' ) );
return;
}
Expand Down Expand Up @@ -131,7 +129,7 @@ function ImportDropZone( { onValidated }: { onValidated: ( file: File ) => void
<input
ref={ fileRef }
type="file"
accept={ ACCEPTED_IMPORT_FILE_TYPES.join( ',' ) }
accept={ ACCEPTED_ADD_SITE_FILE_TYPES.join( ',' ) }
onChange={ handleChange }
className="hidden"
/>
Expand All @@ -145,7 +143,7 @@ function ImportDropZone( { onValidated }: { onValidated: ( file: File ) => void
{ __( 'Import from a backup' ) }
</Heading>
<Text className="text-[13px] !text-frame-text-secondary" weight="400">
{ __( 'Drop a file or click to browse (.zip, .tar.gz, .sql, .wpress, .xml)' ) }
{ __( 'Drop a file or click to browse (.zip, .tar.gz, .wpress, .xml)' ) }
</Text>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import ImportBackup from '../import-backup';

vi.mock( 'src/components/learn-more', () => ( {
LearnMoreLink: () => <span>Learn more</span>,
} ) );

describe( 'ImportBackup', () => {
it( 'rejects SQL selected for a new site while preserving XML support', () => {
const onFileSelect = vi.fn();
render( <ImportBackup onFileSelect={ onFileSelect } /> );
const input = screen.getByLabelText( 'Select backup file' );

expect( input.getAttribute( 'accept' ) ).not.toContain( '.sql' );
expect( input ).toHaveAttribute( 'accept', expect.stringContaining( '.xml' ) );
fireEvent.change( input, {
target: { files: [ new File( [ 'SELECT 1;' ], 'site.sql' ) ] },
} );

expect( onFileSelect ).not.toHaveBeenCalled();
expect( screen.getByText( /This file type is not supported/ ) ).toBeVisible();

const xml = new File( [ '<rss />' ], 'site.xml' );
fireEvent.change( input, { target: { files: [ xml ] } } );
expect( onFileSelect ).toHaveBeenCalledWith( xml );
} );

it( 'rejects a dropped SQL file for a new site', () => {
const onFileSelect = vi.fn();
render( <ImportBackup onFileSelect={ onFileSelect } /> );

fireEvent.drop( screen.getByRole( 'button' ), {
dataTransfer: { files: [ new File( [ 'SELECT 1;' ], 'site.sql' ) ] },
} );

expect( onFileSelect ).not.toHaveBeenCalled();
expect( screen.getByText( /This file type is not supported/ ) ).toBeVisible();
} );
} );
30 changes: 30 additions & 0 deletions apps/ui/src/components/create-site-form/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -443,4 +443,34 @@ describe( 'CreateSiteForm', () => {
)
);
} );

it( 'shows an actionable submission error with optional technical details', () => {
const onCancel = vi.fn();
render(
<CreateSiteForm
initialValues={ { name: 'Imported site', path: '/sites/imported-site' } }
existingDomainNames={ [] }
onSubmit={ vi.fn() }
onCancel={ onCancel }
submitLabel="Retry import"
cancelLabel="Choose another backup"
submitError={ {
title: 'Studio could not import this backup.',
message: 'The incomplete site was removed.',
details: 'CLI command failed',
} }
/>
);

const alert = screen.getByRole( 'alert' );
expect( alert ).toHaveTextContent( 'Studio could not import this backup.' );
expect( alert ).toHaveTextContent( 'The incomplete site was removed.' );
expect( screen.getByRole( 'button', { name: 'Retry import' } ) ).toBeInTheDocument();

fireEvent.click( screen.getByRole( 'button', { name: 'Choose another backup' } ) );
expect( onCancel ).toHaveBeenCalledOnce();

fireEvent.click( screen.getByText( 'View technical details' ) );
expect( screen.getByText( 'CLI command failed' ) ).toBeVisible();
} );
} );
31 changes: 27 additions & 4 deletions apps/ui/src/components/create-site-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,23 @@ export interface CreateSiteFormValues {
adminEmail: string;
}

export interface CreateSiteFormError {
title: string;
message: string;
details?: string;
}

interface CreateSiteFormProps {
initialValues?: Partial< CreateSiteFormValues >;
existingDomainNames: string[];
onSubmit: ( values: CreateSiteFormValues ) => void;
onCancel: () => void;
isSubmitting?: boolean;
isSubmitDisabled?: boolean;
submitError?: string;
submitError?: string | CreateSiteFormError;
submitLabel?: string;
cancelLabel?: string;
loadingAnnouncement?: string;
}

interface FormData {
Expand Down Expand Up @@ -391,6 +399,8 @@ export function CreateSiteForm( {
isSubmitDisabled,
submitError,
submitLabel,
cancelLabel,
loadingAnnouncement,
}: CreateSiteFormProps ) {
const formRef = useRef< HTMLFormElement >( null );
const initialSuggestedFields = getSuggestedFields( initialValues ?? {} );
Expand Down Expand Up @@ -594,15 +604,15 @@ export function CreateSiteForm( {
disabled={ isSubmitting }
>
<Icon icon={ chevronLeft } size={ 16 } />
<span>{ __( 'Back' ) }</span>
<span>{ cancelLabel ?? __( 'Back' ) }</span>
</Button>
<Button
type="submit"
variant="solid"
tone="brand"
disabled={ ! canSubmit }
loading={ isSubmitting }
loadingAnnouncement={ __( 'Creating site' ) }
loadingAnnouncement={ loadingAnnouncement ?? __( 'Creating site' ) }
data-testid="create-site-submit"
>
{ submitLabel ?? __( 'Create site' ) }
Expand Down Expand Up @@ -663,7 +673,20 @@ export function CreateSiteForm( {

{ submitError && (
<div role="alert" className={ styles.submitError }>
{ submitError }
{ typeof submitError === 'string' ? (
submitError
) : (
<>
<strong className={ styles.submitErrorTitle }>{ submitError.title }</strong>
<p className={ styles.submitErrorMessage }>{ submitError.message }</p>
{ submitError.details && (
<details className={ styles.submitErrorDetails }>
<summary>{ __( 'View technical details' ) }</summary>
<pre>{ submitError.details }</pre>
</details>
) }
</>
) }
</div>
) }
</div>
Expand Down
Loading