Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions apps/cli/commands/_events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { sequential } from '@studio/common/lib/sequential';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __ } from '@wordpress/i18n';
import { readCliConfig, SiteData } from 'cli/lib/cli-config/core';
import { getSiteUrl, removeSiteFromConfig } from 'cli/lib/cli-config/sites';
import { removeSiteFromConfig } from 'cli/lib/cli-config/sites';
import {
connectToDaemon,
disconnectFromDaemon,
Expand All @@ -32,7 +32,7 @@ import {
getDaemonBus,
mcpEventsSocketPath,
} from 'cli/lib/daemon-client';
import { getLiveSiteOperation } from 'cli/lib/site-operations';
import { getSiteDetails } from 'cli/lib/site-details';
import { isSiteRunning } from 'cli/lib/site-utils';
import { SocketServer } from 'cli/lib/socket';
import { SITE_PROCESS_PREFIX } from 'cli/lib/wordpress-server-manager';
Expand All @@ -41,12 +41,7 @@ import { Logger, LoggerError } from 'cli/logger';
const logger = new Logger< LoggerAction >();

function toSiteDetails( site: SiteData ) {
return siteDetailsSchema.parse( {
...site,
// Overrides rather than augments `...site` — see the note in `site list`.
operation: getLiveSiteOperation( site ),
url: getSiteUrl( site ),
} );
return siteDetailsSchema.parse( getSiteDetails( site ) );
}

const emitSiteEvent = sequential(
Expand Down Expand Up @@ -200,8 +195,9 @@ export async function runCommand( listener: EventsListener = 'desktop' ): Promis
await connectToDaemon();
logger.reportSuccess( __( 'Connected to process daemon' ) );

await emitAllSitesStatus();

// Subscribed before the status sweep, so a site that starts or stops after
// the sweep reads it still gets an event. Events are sequential, so the
// later one lands last.
const bus = await getDaemonBus();

// Subscribe to IPC events from site processes. We treat a `result` message with no return value
Expand Down Expand Up @@ -234,6 +230,8 @@ export async function runCommand( listener: EventsListener = 'desktop' ): Promis
bus.on( 'daemon-kill', () => {
void emitAllSitesStopped();
} );

await emitAllSitesStatus();
}

export async function commandHandler( listener?: EventsListener ) {
Expand Down
14 changes: 2 additions & 12 deletions apps/cli/commands/site/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import CliTable3 from 'cli-table3';
import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core';
import { getSiteUrl } from 'cli/lib/cli-config/sites';
import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
import { getSiteEmailDirectory } from 'cli/lib/site-email';
import { getLiveSiteOperation } from 'cli/lib/site-operations';
import { getSiteDetails } from 'cli/lib/site-details';
import { pickPublicSiteFields } from 'cli/lib/site-public-fields';
import { isSiteRunning } from 'cli/lib/site-utils';
import { getColumnWidths, getPrettyPath } from 'cli/lib/utils';
Expand Down Expand Up @@ -46,16 +45,7 @@ async function getSiteListData( sites: SiteData[] ): Promise< {
url,
} );

jsonEntries.push( {
...site,
// Overrides the stored value from `...site`: it can still name a
// process that has died, and both front ends decide which site
// actions to disable from what this reports.
operation: getLiveSiteOperation( site ),
url,
running,
emailPath: getSiteEmailDirectory( site.id ),
} );
jsonEntries.push( { ...getSiteDetails( site ), running } );
}

return { tableEntries, jsonEntries };
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/commands/site/tests/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ describe( 'CLI: studio site list', () => {
port: 8080,
phpVersion: '8.0',
url: 'http://localhost:8080',
running: false,
emailPath: getSiteEmailDirectory( 'site-1' ),
running: false,
},
{
id: 'site-2',
Expand All @@ -98,8 +98,8 @@ describe( 'CLI: studio site list', () => {
phpVersion: '8.0',
customDomain: 'my-site.wp.local',
url: 'http://my-site.wp.local',
running: false,
emailPath: getSiteEmailDirectory( 'site-2' ),
running: false,
},
];
expect( consoleSpy ).toHaveBeenCalledWith( JSON.stringify( publicSites ) );
Expand Down
18 changes: 18 additions & 0 deletions apps/cli/lib/site-details.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { getSiteUrl } from 'cli/lib/cli-config/sites';
import { getSiteEmailDirectory } from 'cli/lib/site-email';
import { getLiveSiteOperation } from 'cli/lib/site-operations';
import type { SiteData } from 'cli/lib/cli-config/core';

// A site as Studio shows it, built once for both `site list` and the events
// Studio applies to its list, so the two can't drift apart.
export function getSiteDetails( site: SiteData ) {
return {
...site,
// Overrides the stored value from `...site`: it can still name a process
// that has died, and both front ends decide which site actions to disable
// from what this reports.
operation: getLiveSiteOperation( site ),
url: getSiteUrl( site ),
emailPath: getSiteEmailDirectory( site.id ),
};
}
10 changes: 2 additions & 8 deletions apps/studio/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,17 +653,11 @@ export async function startServer( event: IpcMainInvokeEvent, id: string ): Prom
await startErrorRecovery( details, errorMessage, readProcessManagerLogs, start );
console.log( `[PHP Recovery - ${ id }] Serving PHP error page on port ${ details.port }` );
const url = `http://localhost:${ details.port }`;
// Without the site's details, so the renderer refetches the list, where
// `getSites` gives the error page's URL.
void sendIpcEventToRenderer( 'site-event', {
event: SITE_EVENTS.UPDATED,
siteId: id,
site: {
id,
name: details.name,
path: details.path,
port: details.port,
url,
phpVersion: details.phpVersion,
},
running: true,
} );
// Refresh the thumbnail so it shows the error page instead of a stale capture.
Expand Down
11 changes: 8 additions & 3 deletions apps/studio/src/modules/cli/lib/cli-events-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,14 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void >
return;
}

// A site serving a PHP-error page stays "running" even though the CLI reports it stopped.
const running = event.running || isInErrorRecovery( siteId );
void sendIpcEventToRenderer( 'site-event', { ...event, running } );
// A site serving a PHP-error page stays "running" even though the CLI reports it stopped. Its
// details go without, so the renderer refetches the list, where `getSites` gives the page's URL.
const inErrorRecovery = isInErrorRecovery( siteId );
const running = event.running || inErrorRecovery;
void sendIpcEventToRenderer(
'site-event',
inErrorRecovery ? { event: eventType, siteId, running } : { ...event, running }
);
await seeded;
const wasRunning = lastRunning.get( siteId ) ?? false;
lastRunning.set( siteId, running );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,17 @@ describe( 'CLI events subscriber', () => {
expect( captureSiteThumbnail ).toHaveBeenCalledTimes( 1 );
} );

it( 'reports a site serving the PHP error page as running', async () => {
it( 'reports a site serving the PHP error page as running, without its CLI details', async () => {
await emitSiteEvent( SITE_EVENTS.CREATED, 'site-b', true );
vi.mocked( isInErrorRecovery ).mockReturnValue( true );

await emitSiteEvent( SITE_EVENTS.UPDATED, 'site-b', false );

expect( sendIpcEventToRenderer ).toHaveBeenLastCalledWith(
'site-event',
expect.objectContaining( { siteId: 'site-b', running: true } )
);
expect( sendIpcEventToRenderer ).toHaveBeenLastCalledWith( 'site-event', {
event: SITE_EVENTS.UPDATED,
siteId: 'site-b',
running: true,
} );
} );

it( 'forgets a deleted site', async () => {
Expand Down
89 changes: 89 additions & 0 deletions apps/ui/src/data/queries/use-sites.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SITE_EVENTS } from '@studio/common/lib/cli-events';
import { QueryClientProvider } from '@tanstack/react-query';
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
Expand Down Expand Up @@ -46,6 +47,47 @@ describe( 'useAutoStartSites', () => {
expect( connector.startSite ).toHaveBeenCalledTimes( 1 );
expect( getVisibleToasts() ).toHaveLength( 0 );
} );

it( 'starts from the list read this launch, not the one persisted before the quit', async () => {
const site = { ...makeSite(), url: 'http://localhost:9010' };
const connector = createFakeConnector();
let readList = ( _sites: SiteDetails[] ) => {};
connector.getSites.mockImplementation(
() => new Promise< SiteDetails[] >( ( resolve ) => ( readList = resolve ) )
);
const queryClient = createTestQueryClient();
queryClient.setQueryData( [ 'sites' ], [ site ] );
const { result } = renderHook(
() => {
useSyncSitesWithEvents();
useAutoStartSites();
return useSites();
},
{
wrapper: ( { children } ) => (
<ConnectorProvider connector={ connector }>
<QueryClientProvider client={ queryClient }>{ children }</QueryClientProvider>
</ConnectorProvider>
),
}
);

// The status the CLI reports for each site as the app launches, landing
// before the list is read.
const reported = { ...site, name: 'Renamed' };
act( () =>
connector.emit( 'onSiteEvent', {
event: SITE_EVENTS.UPDATED,
siteId: site.id,
site: reported,
running: false,
} )
);
await waitFor( () => expect( result.current.data?.[ 0 ].name ).toBe( 'Renamed' ) );
await act( async () => readList( [ { ...reported, autoStart: true } ] ) );

await waitFor( () => expect( connector.startSite ).toHaveBeenCalledWith( site.id ) );
} );
} );

describe( 'useCreateSite', () => {
Expand Down Expand Up @@ -154,6 +196,53 @@ describe( 'useStartSite', () => {
} );

describe( 'useSyncSitesWithEvents', () => {
it.each( [
[ 'start', true ],
[ 'stop', false ],
] as const )(
'shows a %s from its events without refetching the list',
async ( action, running ) => {
// What the CLI reports, and the sort order only Studio keeps.
const details = { ...makeSite(), url: 'http://localhost:9010' };
const site = { ...details, running: ! running, sortOrder: 1000 };
const connector = createFakeConnector( { sites: [ site ] } );
connector[ action === 'start' ? 'startSite' : 'stopSite' ].mockImplementation( async () => {
const siteId = site.id;
const held = { ...details, operation: { pid: 1, kind: action } };
connector.emit( 'onSiteEvent', {
event: SITE_EVENTS.UPDATED,
siteId,
site: held,
running,
} );
// An operation's release doesn't know whether the site runs.
connector.emit( 'onSiteEvent', {
event: SITE_EVENTS.OPERATIONS_CHANGED,
siteId,
site: details,
running: ! running,
} );
} );
const { result } = renderHook(
() => {
useSyncSitesWithEvents();
return { sites: useSites(), start: useStartSite(), stop: useStopSite() };
},
{ wrapper: providers( connector ) }
);
await waitFor( () => expect( result.current.sites.data ).toBeDefined() );

await act( async () => {
await result.current[ action ].mutateAsync( site.id );
} );

await waitFor( () =>
expect( result.current.sites.data ).toEqual( [ { ...site, running } ] )
);
expect( connector.getSites ).toHaveBeenCalledTimes( 1 );
}
);

it( 'refetches after an in-flight fetch that predates the event', async () => {
const connector = createFakeConnector();
const pendingFetches: Array< ( sites: SiteDetails[] ) => void > = [];
Expand Down
Loading
Loading