Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
2743dc8
Abort async operations on SIGTERM/SIGINT
fredrikekelund Dec 18, 2025
d34ee1e
Also send abort message to child server
fredrikekelund Dec 18, 2025
9a31e58
Only create AbortController when topic is not `abort`
fredrikekelund Dec 18, 2025
61f3256
Fix
fredrikekelund Dec 18, 2025
f81ba0c
More fixes
fredrikekelund Dec 18, 2025
bd6b4d9
Tweaks
fredrikekelund Dec 18, 2025
eb32b3b
Merge branch 'dev/studio-cli-i2' into f26d/cli-abort-async-operations
fredrikekelund Dec 18, 2025
a6a7e7a
Fix unit tests
fredrikekelund Dec 18, 2025
27bd5c3
Merge branch 'dev/studio-cli-i2' into f26d/cli-abort-async-operations
fredrikekelund Dec 19, 2025
569058f
Fix types
fredrikekelund Dec 19, 2025
8786163
Remove `this.sessionPath` files individually
fredrikekelund Dec 19, 2025
abbcfc8
Stop running servers in a detached process
fredrikekelund Dec 19, 2025
fa0537e
Revert "Remove `this.sessionPath` files individually"
fredrikekelund Dec 19, 2025
c984234
Retry
fredrikekelund Dec 19, 2025
87e62ec
Increase timeouts
fredrikekelund Dec 19, 2025
d42d25e
Fix deprecated blueprint syntax
fredrikekelund Dec 19, 2025
3f9ef30
Merge branch 'dev/studio-cli-i2' into f26d/fix-e2e-tests-windows
fredrikekelund Dec 19, 2025
10955cf
Increase timeout
fredrikekelund Dec 19, 2025
fc4fa56
Try adding a small delay
fredrikekelund Dec 19, 2025
814d4ae
Kill `site list --watch` on SIGINT
fredrikekelund Dec 19, 2025
96e03aa
Create main window after creating site watcher
fredrikekelund Dec 19, 2025
ef932ca
Try using async fs method for cleanup
fredrikekelund Dec 22, 2025
0354512
Try rimraf (which has advanced retry strategies)
fredrikekelund Dec 22, 2025
1511151
Merge branch 'dev/studio-cli-i2' into f26d/fix-e2e-tests-windows
fredrikekelund Jan 2, 2026
71c5d1f
Merge branch 'dev/studio-cli-i2' into f26d/fix-e2e-tests-windows
fredrikekelund Jan 7, 2026
07fc679
New approach: don't remove `sessionPath` dir
fredrikekelund Jan 7, 2026
055d14c
Unused import
fredrikekelund Jan 7, 2026
0d0dc26
Force close app
fredrikekelund Jan 7, 2026
bc83d93
disconnect from pm2 in response to SIGTERM
fredrikekelund Jan 8, 2026
8264bbe
Revert user data watcher changes from #2313
fredrikekelund Jan 8, 2026
1c0cfda
Wait for running button
fredrikekelund Jan 8, 2026
e12e4ad
Use Electron's will-quit event in `execute-command.ts`
fredrikekelund Jan 8, 2026
21b2e77
SIGINT and SIGTERM listeners in `wp` command
fredrikekelund Jan 8, 2026
6355215
More SIGINT and SIGTERM listeners in `wp` command
fredrikekelund Jan 8, 2026
22bdd58
Merge branch 'dev/studio-cli-i2' into f26d/fix-e2e-tests-windows
fredrikekelund Jan 8, 2026
0d16ae5
Temporarily skip blueprints test
fredrikekelund Jan 8, 2026
d64e35a
Revert "Temporarily skip blueprints test"
fredrikekelund Jan 8, 2026
07eaa56
Try with force kill again
fredrikekelund Jan 8, 2026
1534b2c
Logging
fredrikekelund Jan 8, 2026
f75e007
New approach to waiting for app close
fredrikekelund Jan 8, 2026
bc41e38
Logging again
fredrikekelund Jan 8, 2026
f99f4b0
Try to make all child processes detached
fredrikekelund Jan 8, 2026
3596bac
Experiment
fredrikekelund Jan 8, 2026
4b7364a
Revert "Experiment"
fredrikekelund Jan 8, 2026
c0ab642
Revert "Try to make all child processes detached"
fredrikekelund Jan 8, 2026
1682341
Try a 5s timeout for closing the app
fredrikekelund Jan 8, 2026
e2faab8
Experiment with removing stopAllServersOnQuit
fredrikekelund Jan 8, 2026
9d56d0c
shutdown message
bcotrim Jan 8, 2026
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
3 changes: 3 additions & 0 deletions cli/commands/site/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,9 @@ export async function runCommand( format: 'table' | 'json', watch: boolean ): Pr
},
{ debounceMs: 500 }
);

process.on( 'SIGINT', disconnect );
Comment on lines +129 to +130
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The studio site list --watch command would previously not interrupt in response to Ctrl+C

process.on( 'SIGTERM', disconnect );
}
} finally {
if ( ! watch ) {
Expand Down
6 changes: 6 additions & 0 deletions cli/commands/wp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export async function runCommand(
const useCustomPhpVersion = options.phpVersion && options.phpVersion !== site.phpVersion;

if ( ! useCustomPhpVersion ) {
process.on( 'SIGINT', disconnect );
process.on( 'SIGTERM', disconnect );

try {
await connect();

Expand All @@ -40,6 +43,9 @@ export async function runCommand(
}
}

process.on( 'SIGINT', () => process.exit( 1 ) );
process.on( 'SIGTERM', () => process.exit( 1 ) );

// …If not, instantiate a new Playground instance
const [ response, closeWpCliServer ] = await runWpCliCommand(
siteFolder,
Expand Down
18 changes: 18 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { bumpAggregatedUniqueStat, AppdataProvider, LastBumpStatsData } from 'co
import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning';
import { StatsGroup, StatsMetric } from 'common/types/stats';
import yargs from 'yargs';
import { disconnect } from 'cli/lib/pm2-manager';
import { registerCommand as registerAuthLoginCommand } from 'cli/commands/auth/login';
import { registerCommand as registerAuthLogoutCommand } from 'cli/commands/auth/logout';
import { registerCommand as registerAuthStatusCommand } from 'cli/commands/auth/status';
Expand All @@ -28,6 +29,23 @@ import { version } from '../package.json';

suppressPunycodeWarning();

// Handle shutdown message from parent process (Electron app).
// On Windows, child.kill() doesn't send SIGTERM, so we use IPC to notify
// the CLI to clean up (e.g., disconnect from PM2) before terminating.
// Only add this listener when running with IPC channel (from Electron app).
if ( process.send ) {
process.on( 'message', ( message: unknown ) => {
if ( message && typeof message === 'object' && 'type' in message && message.type === 'shutdown' ) {
disconnect();
process.exit( 0 );
}
} );
// Allow the process to exit naturally when the main work is done,
// even though we have a message listener. The IPC channel will be
// cleaned up when the parent terminates.
process.channel?.unref();
}

const cliAppdataProvider: AppdataProvider< LastBumpStatsData > = {
load: readAppdata,
lock: lockAppdata,
Expand Down
7 changes: 2 additions & 5 deletions cli/lib/wordpress-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,8 @@ const SITE_PROCESS_PREFIX = 'studio-site-';
// Get an abort signal that's triggered on SIGINT/SIGTERM. This is useful for aborting and cleaning
// up async operations.
const abortController = new AbortController();
function handleProcessTermination() {
abortController.abort();
}
process.on( 'SIGINT', handleProcessTermination );
process.on( 'SIGTERM', handleProcessTermination );
process.on( 'SIGINT', () => abortController.abort() );
process.on( 'SIGTERM', () => abortController.abort() );

function getProcessName( siteId: string ): string {
return `${ SITE_PROCESS_PREFIX }${ siteId }`;
Expand Down
14 changes: 7 additions & 7 deletions e2e/blueprints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test.describe( 'Blueprints', () => {
await onboarding.closeWhatsNew();

const siteContent = new SiteContent( session.mainWindow, DEFAULT_SITE_NAME );
await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 120_000 } );
await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 200_000 } );
} );

test.afterAll( async () => {
Expand Down Expand Up @@ -49,7 +49,7 @@ test.describe( 'Blueprints', () => {

// Wait for site to be created and running
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } );

// Navigate to Settings tab to get admin URL
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down Expand Up @@ -85,7 +85,7 @@ test.describe( 'Blueprints', () => {

// Wait for site to be created and running
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } );

// Navigate to Settings tab to get admin URL
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down Expand Up @@ -123,7 +123,7 @@ test.describe( 'Blueprints', () => {

// Wait for site to be created and running
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } );

// Navigate to Settings tab to get admin URL
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down Expand Up @@ -159,7 +159,7 @@ test.describe( 'Blueprints', () => {

// Wait for site to be created and running
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } );

// Navigate to Settings tab to get admin URL
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down Expand Up @@ -197,7 +197,7 @@ test.describe( 'Blueprints', () => {

// Wait for site to be created and running
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } );

// Navigate to Settings tab to verify site is accessible
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down Expand Up @@ -236,7 +236,7 @@ test.describe( 'Blueprints', () => {

// Wait for site to be created and running
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 300_000 } );

// Navigate to Settings tab to verify site is accessible
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down
61 changes: 34 additions & 27 deletions e2e/e2e-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,40 +39,43 @@ export class E2ESession {
};
await fs.writeFile( appdataPath, JSON.stringify( initialAppdata, null, 2 ) );

// find the latest build in the out directory
const latestBuild = findLatestBuild();
await this.launchFirstWindow( testEnv );
}

// parse the packaged Electron app and find paths and other info
const appInfo = parseElectronApp( latestBuild );
let executablePath = appInfo.executable;
if ( appInfo.platform === 'win32' ) {
// `parseElectronApp` function obtains the executable path by finding the first executable from the build folder.
// We need to ensure that the executable is the Studio app.
executablePath = executablePath.replace( 'Squirrel.exe', 'Studio.exe' );
// Close the app but keep the data for persistence testing
async restart() {
await this.closeApp();
await this.launchFirstWindow();
}

private async closeApp() {
console.log( 'closeApp: starting' );
if ( ! this.electronApp ) {
console.log( 'closeApp: no electronApp' );
return;
}

this.electronApp = await electron.launch( {
args: [ appInfo.main ], // main file from package.json
executablePath, // path to the Electron executable
env: {
...process.env,
...testEnv,
E2E: 'true', // allow app to determine whether it's running as an end-to-end test
E2E_APP_DATA_PATH: this.appDataPath,
E2E_HOME_PATH: this.homePath,
},
timeout: 60_000,
const childProcess = this.electronApp.process();
console.log( 'closeApp: calling electronApp.close() for pid', childProcess.pid );
// Cap `ElectronApplication::close` call at 5s to prevent timeout issues on Windows
await new Promise( ( resolve, reject ) => {
Promise.race( [
this.electronApp.close(),
new Promise( ( resolve ) => setTimeout( resolve, 5000 ) ),
] )
.then( resolve )
.catch( reject );
} );
this.mainWindow = await this.electronApp.firstWindow( { timeout: 60_000 } );
console.log( 'closeApp: close() returned' );
}

// Close the app but keep the data for persistence testing
async restart() {
await this.electronApp?.close();
private async launchFirstWindow( testEnv: NodeJS.ProcessEnv = {} ) {
const latestBuild = findLatestBuild();
const appInfo = parseElectronApp( latestBuild );
let executablePath = appInfo.executable;
if ( appInfo.platform === 'win32' ) {
// `parseElectronApp` function obtains the executable path by finding the first executable from
// the build folder. We need to ensure that the executable is the Studio app.
executablePath = executablePath.replace( 'Squirrel.exe', 'Studio.exe' );
}

Expand All @@ -81,6 +84,7 @@ export class E2ESession {
executablePath,
env: {
...process.env,
...testEnv,
E2E: 'true',
E2E_APP_DATA_PATH: this.appDataPath,
E2E_HOME_PATH: this.homePath,
Expand All @@ -91,8 +95,11 @@ export class E2ESession {
}

async cleanup() {
await this.electronApp?.close();
// Clean up temporary folder to hold application data
fs.rmSync( this.sessionPath, { recursive: true, force: true } );
await this.closeApp();

// Removing the `sessionPath` directory has proven to be difficult, especially on Windows. Since
// session paths are unique, the WordPress installations are relatively small, and the E2E tests
// primarily run in ephemeral CI workers, we've decided to fix this issue by simply not removing
// the `sessionPath` directory.
}
}
4 changes: 2 additions & 2 deletions e2e/fixtures/blueprints/activate-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"steps": [
{
"step": "installPlugin",
"pluginZipFile": {
"pluginData": {
"resource": "wordpress.org/plugins",
"slug": "hello-dolly"
}
Expand All @@ -14,4 +14,4 @@
"pluginPath": "hello-dolly/hello.php"
}
]
}
}
4 changes: 2 additions & 2 deletions e2e/fixtures/blueprints/activate-theme.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"steps": [
{
"step": "installTheme",
"themeZipFile": {
"themeData": {
"resource": "wordpress.org/themes",
"slug": "twentytwentyone"
}
Expand All @@ -14,4 +14,4 @@
"themeFolderName": "twentytwentyone"
}
]
}
}
4 changes: 2 additions & 2 deletions e2e/fixtures/blueprints/install-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
"steps": [
{
"step": "installPlugin",
"pluginZipFile": {
"pluginData": {
"resource": "wordpress.org/plugins",
"slug": "akismet"
}
}
]
}
}
4 changes: 2 additions & 2 deletions e2e/fixtures/blueprints/install-theme.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
"steps": [
{
"step": "installTheme",
"themeZipFile": {
"themeData": {
"resource": "wordpress.org/themes",
"slug": "twentytwentytwo"
}
}
]
}
}
2 changes: 1 addition & 1 deletion e2e/localization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ test.describe( 'Localization', () => {

// Wait for site to be created
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 200_000 } );

const settingsTabButton = session.mainWindow.getByRole( 'tab', { name: /Settings|設定/i } );
await settingsTabButton.click();
Expand Down
2 changes: 1 addition & 1 deletion e2e/site-navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ test.describe( 'Site Navigation', () => {

// Wait for default site to be ready and get URLs
const siteContent = new SiteContent( session.mainWindow, siteName );
await expect( siteContent.siteNameHeading ).toBeVisible( { timeout: 120_000 } );
await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } );

// Get site URLs for tests
const settingsTab = await siteContent.navigateToTab( 'Settings' );
Expand Down
Loading