From d5aacb09d93bb3d88a5161b5396f1e74447f4253 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Tue, 24 Feb 2026 20:25:13 +0000 Subject: [PATCH 01/10] Add React Doctor CI step to catch React code health regressions Runs in parallel with lint and tests. Posts a Buildkite annotation with the full diagnostics and fails if the score drops below 95 or if error-level issues are found. Co-Authored-By: Claude Opus 4.6 --- .buildkite/commands/run-react-doctor.sh | 73 +++++++++++++++++++++++++ .buildkite/pipeline.yml | 31 +++++++---- 2 files changed, 93 insertions(+), 11 deletions(-) create mode 100755 .buildkite/commands/run-react-doctor.sh diff --git a/.buildkite/commands/run-react-doctor.sh b/.buildkite/commands/run-react-doctor.sh new file mode 100755 index 0000000000..bd7c04f1cc --- /dev/null +++ b/.buildkite/commands/run-react-doctor.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if .buildkite/commands/should-skip-job.sh --job-type validation; then + exit 0 +fi + +echo '--- :package: Install deps' +bash .buildkite/commands/install-node-dependencies.sh + +echo '--- :react: React Doctor' + +SCORE_THRESHOLD=95 +DOCTOR_EXIT=0 + +# Run react-doctor and capture output +# Don't use --offline so score gets calculated +# Use --fail-on error to catch error-level issues +OUTPUT=$(npx -y react-doctor --no-ami --yes --fail-on error 2>&1) || DOCTOR_EXIT=$? + +echo "$OUTPUT" + +# Parse score from output (format: "XX / 100") +SCORE=$(echo "$OUTPUT" | grep -oE '[0-9]+ / 100' | head -1 | grep -oE '^[0-9]+') || true + +# Post annotation to Buildkite UI +if command -v buildkite-agent &> /dev/null; then + # Strip ANSI escape codes for the annotation + CLEAN_OUTPUT=$(echo "$OUTPUT" | sed $'s/\x1b\\[[0-9;]*m//g') + + if [ -n "$SCORE" ]; then + if [ "$SCORE" -lt "$SCORE_THRESHOLD" ]; then + STYLE="error" + HEADER="React Doctor Score: ${SCORE}/100 (below threshold of ${SCORE_THRESHOLD})" + else + STYLE="success" + HEADER="React Doctor Score: ${SCORE}/100" + fi + else + STYLE="warning" + HEADER="React Doctor (score not available)" + fi + + cat < +Full diagnostics + +\`\`\` +${CLEAN_OUTPUT} +\`\`\` + + +EOF +fi + +# Fail if react-doctor itself failed (--fail-on error) +if [ "$DOCTOR_EXIT" -ne 0 ]; then + echo "^^^ +++" + echo "React Doctor found error-level issues (exit code: ${DOCTOR_EXIT})" + exit 1 +fi + +# Fail if score is below threshold +if [ -n "$SCORE" ] && [ "$SCORE" -lt "$SCORE_THRESHOLD" ]; then + echo "^^^ +++" + echo "React Doctor score ${SCORE}/100 is below the threshold of ${SCORE_THRESHOLD}/100" + exit 1 +fi + +echo "React Doctor score: ${SCORE:-unknown}/100 (threshold: ${SCORE_THRESHOLD}/100)" diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index e0d75e03aa..1594e667de 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -1,6 +1,5 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json --- - # IMAGE_ID is an env var that only macOS agents need. # Defining it at the root level propagates it too all agents, which can seem unnecessary but is a the same time convenient and DRY. env: @@ -17,12 +16,22 @@ steps: - github_commit_status: context: Lint + - label: ':react: React Doctor' + agents: + queue: mac + key: react_doctor + command: bash .buildkite/commands/run-react-doctor.sh + plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] + notify: + - github_commit_status: + context: React Doctor + - label: Unit Tests on {{matrix}} key: unit_tests command: bash .buildkite/commands/run-unit-tests.sh "{{matrix}}" plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] agents: - queue: "{{matrix}}" + queue: '{{matrix}}' matrix: - mac - windows @@ -42,10 +51,10 @@ steps: - test-results/**/*error-context.md plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] agents: - queue: "{{matrix.platform}}" + queue: '{{matrix.platform}}' env: # See https://playwright.dev/docs/ci#debugging-browser-launches - DEBUG: "pw:browser" + DEBUG: 'pw:browser' matrix: setup: { platform: [], arch: [] } adjustments: @@ -58,7 +67,7 @@ steps: - github_commit_status: context: E2E Tests - - label: ":chart_with_upwards_trend: Performance Metrics" + - label: ':chart_with_upwards_trend: Performance Metrics' key: metrics agents: queue: mac @@ -73,8 +82,8 @@ steps: - github_commit_status: context: Performance Metrics - - input: "🚦 Build for Mac?" - prompt: "Do you want to build Mac dev binaries for this PR?" + - input: '🚦 Build for Mac?' + prompt: 'Do you want to build Mac dev binaries for this PR?' key: input-dev-mac if: build.tag !~ /^v[0-9]+/ && build.branch != 'trunk' @@ -127,8 +136,8 @@ steps: - x64 - arm64 - - input: "🚦 Build for Windows?" - prompt: "Do you want to build Windows dev binaries for this PR?" + - input: '🚦 Build for Windows?' + prompt: 'Do you want to build Windows dev binaries for this PR?' key: input-dev-windows if: build.tag !~ /^v[0-9]+/ && build.branch != 'trunk' @@ -151,7 +160,7 @@ steps: - x64 - arm64 - - label: ":rocket: Distribute Dev Builds" + - label: ':rocket: Distribute Dev Builds' command: | echo "--- :node: Downloading Binaries" buildkite-agent artifact download "*.app.zip" . @@ -251,7 +260,7 @@ steps: context: All Windows Release Builds if: build.tag =~ /^v[0-9]+/ - - label: ":rocket: Publish Release Builds" + - label: ':rocket: Publish Release Builds' command: | echo "--- :node: Downloading Binaries" buildkite-agent artifact download "*.zip" . From 2a356e22dd051279520ebd6aa53fb515f8eeffbd Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Tue, 24 Feb 2026 20:32:34 +0000 Subject: [PATCH 02/10] Add no-op comment to trigger react-doctor CI step Co-Authored-By: Claude Opus 4.6 --- apps/studio/src/index.ts | 297 ++++++++++++++++++++------------------- 1 file changed, 149 insertions(+), 148 deletions(-) diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index a44d16f30c..c8d4cce73e 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -1,3 +1,4 @@ +// no-op: trigger react-doctor CI step import { app, BrowserWindow, @@ -67,52 +68,52 @@ import packageJson from '../package.json'; // Helper function to get the actual URL for validation function getRendererUrl(): string { - if ( ! app.isPackaged && process.env[ 'ELECTRON_RENDERER_URL' ] ) { - return process.env[ 'ELECTRON_RENDERER_URL' ]; + if (!app.isPackaged && process.env['ELECTRON_RENDERER_URL']) { + return process.env['ELECTRON_RENDERER_URL']; } else { // For production file paths, convert to file:// URL - return pathToFileURL( path.join( __dirname, '../renderer/index.html' ) ).href; + return pathToFileURL(path.join(__dirname, '../renderer/index.html')).href; } } -if ( ! process.env.IS_DEV_BUILD ) { - const { sentryRelease, isDevEnvironment } = getSentryReleaseInfo( app.getVersion() ); +if (!process.env.IS_DEV_BUILD) { + const { sentryRelease, isDevEnvironment } = getSentryReleaseInfo(app.getVersion()); - Sentry.init( { + Sentry.init({ dsn: 'https://97693275b2716fb95048c6d12f4318cf@o248881.ingest.sentry.io/4506612776501248', debug: true, - enabled: ! isDevEnvironment, + enabled: !isDevEnvironment, release: sentryRelease, environment: isDevEnvironment ? 'development' : 'production', - } ); + }); } suppressPunycodeWarning(); -const appAppdataProvider: AppdataProvider< LastBumpStatsData > = { +const appAppdataProvider: AppdataProvider = { load: loadUserData, lock: lockAppdata, unlock: unlockAppdata, - save: async ( data ) => { + save: async (data) => { // Cast is safe: data comes from loadUserData() which returns the full UserData type. // The lock/unlock is already handled by the caller (updateLastBump in /common/lib/bump-stat.ts) // eslint-disable-next-line studio/require-lock-before-save - await saveUserData( data as never ); + await saveUserData(data as never); }, }; // Handle creating/removing shortcuts on Windows when installing/uninstalling. -const isInInstaller = require( 'electron-squirrel-startup' ); +const isInInstaller = require('electron-squirrel-startup'); // Ensure we're the only instance of the app running const gotTheLock = app.requestSingleInstanceLock(); let finishedInitialization = false; -if ( gotTheLock && ! isInInstaller ) { +if (gotTheLock && !isInInstaller) { void appBoot(); -} else if ( ! gotTheLock ) { +} else if (!gotTheLock) { app.quit(); } @@ -120,14 +121,14 @@ async function setupSentryUserId() { try { await lockAppdata(); const userData = await loadUserData(); - if ( ! userData.sentryUserId ) { + if (!userData.sentryUserId) { userData.sentryUserId = crypto.randomUUID(); } - console.log( 'Setting Sentry user ID:', userData.sentryUserId ); - Sentry.setUser( { id: userData.sentryUserId } ); + console.log('Setting Sentry user ID:', userData.sentryUserId); + Sentry.setUser({ id: userData.sentryUserId }); - await saveUserData( userData ); + await saveUserData(userData); } finally { await unlockAppdata(); } @@ -136,22 +137,22 @@ async function setupSentryUserId() { // This is a workaround to ensure that the extension background workers are started // If you are updating Electron, confirm if this is still needed // https://github.com/electron/electron/issues/41613 -function launchExtensionBackgroundWorkers( appSession = session.defaultSession ) { - const extensionApi = ( appSession.extensions as Electron.Extensions | undefined ) || appSession; +function launchExtensionBackgroundWorkers(appSession = session.defaultSession) { + const extensionApi = (appSession.extensions as Electron.Extensions | undefined) || appSession; return Promise.all( - extensionApi.getAllExtensions().map( async ( extension ) => { + extensionApi.getAllExtensions().map(async (extension) => { const manifest = extension.manifest; - if ( manifest.manifest_version === 3 && manifest?.background?.service_worker ) { - await appSession.serviceWorkers.startWorkerForScope( extension.url ); + if (manifest.manifest_version === 3 && manifest?.background?.service_worker) { + await appSession.serviceWorkers.startWorkerForScope(extension.url); } - } ) + }) ); } async function appBoot() { - app.setName( packageJson.productName ); + app.setName(packageJson.productName); - Menu.setApplicationMenu( null ); + Menu.setApplicationMenu(null); setupCustomProtocolHandler(); @@ -159,14 +160,14 @@ async function appBoot() { setupUpdates(); - if ( process.defaultApp ) { - if ( process.argv.length >= 2 ) { - app.setAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, [ - path.resolve( process.argv[ 1 ] ), - ] ); + if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL_PREFIX, process.execPath, [ + path.resolve(process.argv[1]), + ]); } } else { - app.setAsDefaultProtocolClient( PROTOCOL_PREFIX ); + app.setAsDefaultProtocolClient(PROTOCOL_PREFIX); } // Forces all renderers to be sandboxed. IPC is the only way render processes will @@ -174,120 +175,120 @@ async function appBoot() { app.enableSandbox(); // Prevent navigation to anywhere other than known locations - app.on( 'web-contents-created', ( _event, contents ) => { - contents.on( 'will-navigate', ( event, navigationUrl ) => { - const { origin } = new URL( navigationUrl ); - const allowedOrigins = [ new URL( getRendererUrl() ).origin ]; - if ( ! allowedOrigins.includes( origin ) ) { + app.on('web-contents-created', (_event, contents) => { + contents.on('will-navigate', (event, navigationUrl) => { + const { origin } = new URL(navigationUrl); + const allowedOrigins = [new URL(getRendererUrl()).origin]; + if (!allowedOrigins.includes(origin)) { event.preventDefault(); } - } ); - contents.setWindowOpenHandler( () => { + }); + contents.setWindowOpenHandler(() => { return { action: 'deny' }; - } ); - } ); + }); + }); - function validateIpcSender( event: IpcMainInvokeEvent ) { - if ( ! event.senderFrame ) { + function validateIpcSender(event: IpcMainInvokeEvent) { + if (!event.senderFrame) { throw new Error( 'Failed IPC sender validation check: the frame has either navigated or been destroyed' ); } - if ( new URL( event.senderFrame.url ).origin === new URL( getRendererUrl() ).origin ) { + if (new URL(event.senderFrame.url).origin === new URL(getRendererUrl()).origin) { return true; } - throw new Error( 'Failed IPC sender validation check: ' + event.senderFrame.url ); + throw new Error('Failed IPC sender validation check: ' + event.senderFrame.url); } function setupIpc() { - const ipcHandlerEntries = Object.entries( ipcHandlers ) as [ + const ipcHandlerEntries = Object.entries(ipcHandlers) as [ keyof typeof ipcHandlers, - ( ...args: unknown[] ) => unknown, + (...args: unknown[]) => unknown, ][]; - for ( const [ key, handler ] of ipcHandlerEntries ) { - if ( IPC_VOID_HANDLERS.find( ( handler ) => handler === key ) ) { - ipcMain.on( key, function ( event, ...args: unknown[] ) { + for (const [key, handler] of ipcHandlerEntries) { + if (IPC_VOID_HANDLERS.find((handler) => handler === key)) { + ipcMain.on(key, function (event, ...args: unknown[]) { try { - validateIpcSender( event ); - handler( event, ...args ); - } catch ( error ) { - console.error( error ); + validateIpcSender(event); + handler(event, ...args); + } catch (error) { + console.error(error); throw error; } - } ); + }); } else { - ipcMain.handle( key, function ( event, ...args: unknown[] ) { + ipcMain.handle(key, function (event, ...args: unknown[]) { try { - validateIpcSender( event ); - return handler( event, ...args ); - } catch ( error ) { - console.error( error ); + validateIpcSender(event); + return handler(event, ...args); + } catch (error) { + console.error(error); throw error; } - } ); + }); } } } function setupCustomProtocolHandler() { - if ( process.platform === 'darwin' ) { - app.on( 'open-url', ( _event, url ) => { - void handleDeeplink( url ); - } ); + if (process.platform === 'darwin') { + app.on('open-url', (_event, url) => { + void handleDeeplink(url); + }); } else { // Handle custom protocol links on Windows and Linux - app.on( 'second-instance', async ( _event, argv ) => { - if ( ! finishedInitialization ) { + app.on('second-instance', async (_event, argv) => { + if (!finishedInitialization) { return; } const mainWindow = await getMainWindow(); // CLI commands are likely invoked from other apps, so we need to avoid changing app focus. - const isCLI = argv?.find( ( arg ) => arg.startsWith( '--cli=' ) ); - if ( ! isCLI ) { - if ( mainWindow.isMinimized() ) mainWindow.restore(); + const isCLI = argv?.find((arg) => arg.startsWith('--cli=')); + if (!isCLI) { + if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); } - const customProtocolParameter = argv?.find( ( arg ) => arg.startsWith( PROTOCOL_PREFIX ) ); - if ( customProtocolParameter ) { - void handleDeeplink( customProtocolParameter ); + const customProtocolParameter = argv?.find((arg) => arg.startsWith(PROTOCOL_PREFIX)); + if (customProtocolParameter) { + void handleDeeplink(customProtocolParameter); } - } ); + }); } } - app.on( 'ready', async () => { + app.on('ready', async () => { const locale = await getUserLocaleWithFallback(); - if ( process.env.NODE_ENV === 'development' ) { - await installExtension( REACT_DEVELOPER_TOOLS ); - await installExtension( REDUX_DEVTOOLS ); + if (process.env.NODE_ENV === 'development') { + await installExtension(REACT_DEVELOPER_TOOLS); + await installExtension(REDUX_DEVTOOLS); await launchExtensionBackgroundWorkers(); } - console.log( `App version: ${ app.getVersion() }` ); - console.log( `Environment: ${ process.env.NODE_ENV ?? 'undefined' }` ); - console.log( `Built from commit: ${ COMMIT_HASH ?? 'undefined' }` ); - console.log( `Local timezone: ${ Intl.DateTimeFormat().resolvedOptions().timeZone }` ); - console.log( `App locale: ${ app.getLocale() }` ); - console.log( `System locale: ${ app.getSystemLocale() }` ); - console.log( `Used language: ${ locale }` ); + console.log(`App version: ${app.getVersion()}`); + console.log(`Environment: ${process.env.NODE_ENV ?? 'undefined'}`); + console.log(`Built from commit: ${COMMIT_HASH ?? 'undefined'}`); + console.log(`Local timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`); + console.log(`App locale: ${app.getLocale()}`); + console.log(`System locale: ${app.getSystemLocale()}`); + console.log(`Used language: ${locale}`); // By default Electron automatically approves all permissions requests (e.g. notifications, webcam) // We'll opt-in to permissions we specifically need instead. - session.defaultSession.setPermissionRequestHandler( ( webContents, permission, callback ) => { + session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { // Reject all permission requests - callback( false ); - } ); + callback(false); + }); - session.defaultSession.webRequest.onHeadersReceived( ( details, callback ) => { + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { // Only set a custom CSP header the main window UI. For other pages (like login) we should // use the CSP provided by the server, which is more likely to be up-to-date and complete. - if ( details.url !== getRendererUrl() ) { - callback( details ); + if (details.url !== getRendererUrl()) { + callback(details); return; } @@ -309,25 +310,25 @@ async function appBoot() { ]; const policies = [ ...basePolicies, - ...( process.env.NODE_ENV === 'development' ? devPolicies : prodPolicies ), + ...(process.env.NODE_ENV === 'development' ? devPolicies : prodPolicies), ]; - callback( { + callback({ ...details, responseHeaders: { ...details.responseHeaders, - 'Content-Security-Policy': [ policies.filter( Boolean ).join( '; ' ) ], + 'Content-Security-Policy': [policies.filter(Boolean).join('; ')], }, - } ); - } ); + }); + }); setupIpc(); - await setupWPServerFiles().catch( Sentry.captureException ); + await setupWPServerFiles().catch(Sentry.captureException); // WordPress server files are updated asynchronously to avoid delaying app initialization - updateWPServerFiles().catch( Sentry.captureException ); + updateWPServerFiles().catch(Sentry.captureException); - if ( await needsToMigrateFromWpNowFolder() ) { + if (await needsToMigrateFromWpNowFolder()) { await migrateFromWpNowFolder(); } @@ -344,40 +345,40 @@ async function appBoot() { const userData = await loadUserData(); // Bump stats for the first time the app runs - this is when no lastBumpStats are available - if ( ! userData.lastBumpStats ) { - bumpStat( StatsGroup.STUDIO_APP_LAUNCH, getPlatformMetric( process.platform ) ); + if (!userData.lastBumpStats) { + bumpStat(StatsGroup.STUDIO_APP_LAUNCH, getPlatformMetric(process.platform)); } // Bump a stat on each app launch, approximates total app launches - bumpStat( StatsGroup.STUDIO_APP_LAUNCH_TOTAL, getPlatformMetric( process.platform ) ); + bumpStat(StatsGroup.STUDIO_APP_LAUNCH_TOTAL, getPlatformMetric(process.platform)); // Bump stat for unique weekly app launch, approximates weekly active users bumpAggregatedUniqueStat( StatsGroup.STUDIO_APP_LAUNCH_UNIQUE, - getPlatformMetric( process.platform ), + getPlatformMetric(process.platform), 'weekly', appAppdataProvider - ).catch( ( err ) => Sentry.captureException( err ) ); + ).catch((err) => Sentry.captureException(err)); // Bump stat for unique monthly app launch, approximates monthly active users bumpAggregatedUniqueStat( StatsGroup.STUDIO_APP_LAUNCH_UNIQUE_MONTHLY, - getPlatformMetric( process.platform ), + getPlatformMetric(process.platform), 'monthly', appAppdataProvider - ).catch( ( err ) => Sentry.captureException( err ) ); + ).catch((err) => Sentry.captureException(err)); await updateWindowsCliVersionedPathIfNeeded(); finishedInitialization = true; - } ); + }); // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. - app.on( 'window-all-closed', () => { - if ( process.platform !== 'darwin' ) { + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') { app.quit(); } - } ); + }); /** * We want to stop all running sites (including the process daemon) in any of these cases: @@ -388,63 +389,63 @@ async function appBoot() { let shouldStopSitesOnQuit = true; let isQuittingConfirmed = false; - app.on( 'before-quit', ( event ) => { - if ( isQuittingConfirmed ) { + app.on('before-quit', (event) => { + if (isQuittingConfirmed) { return; } - if ( hasActiveSyncOperations() ) { + if (hasActiveSyncOperations()) { const QUIT_APP_BUTTON_INDEX = 0; const CANCEL_BUTTON_INDEX = 1; - const messageInformation: Pick< MessageBoxSyncOptions, 'message' | 'detail' | 'type' > = + const messageInformation: Pick = hasUploadingPushOperations() ? { - message: __( 'Sync is in progress' ), + message: __('Sync is in progress'), detail: __( "There's a sync operation in progress. Quitting the app will abort that operation. Are you sure you want to quit?" ), type: 'warning', - } + } : { - message: __( 'Sync will continue' ), + message: __('Sync will continue'), detail: __( 'The sync process will continue running remotely after you quit Studio. We will send you an email once it is complete.' ), type: 'info', - }; + }; - const clickedButtonIndex = dialog.showMessageBoxSync( { + const clickedButtonIndex = dialog.showMessageBoxSync({ message: messageInformation.message, detail: messageInformation.detail, type: messageInformation.type, - buttons: [ __( 'Yes, quit the app' ), __( 'No, take me back' ) ], + buttons: [__('Yes, quit the app'), __('No, take me back')], cancelId: CANCEL_BUTTON_INDEX, defaultId: QUIT_APP_BUTTON_INDEX, - } ); + }); - if ( clickedButtonIndex === CANCEL_BUTTON_INDEX ) { + if (clickedButtonIndex === CANCEL_BUTTON_INDEX) { event.preventDefault(); return; } } const runningSiteCount = getRunningSiteCount(); - if ( getAutoUpdaterState() !== 'waiting-for-restart' && runningSiteCount > 0 ) { + if (getAutoUpdaterState() !== 'waiting-for-restart' && runningSiteCount > 0) { event.preventDefault(); - void ( async () => { + void (async () => { const userData = await loadUserData(); const isCliInstalled = await isStudioCliInstalled(); - if ( userData.stopSitesOnQuit !== undefined ) { + if (userData.stopSitesOnQuit !== undefined) { shouldStopSitesOnQuit = userData.stopSitesOnQuit; isQuittingConfirmed = true; app.quit(); return; } - if ( ! isCliInstalled || process.env.E2E ) { + if (!isCliInstalled || process.env.E2E) { isQuittingConfirmed = true; app.quit(); return; @@ -453,9 +454,9 @@ async function appBoot() { const STOP_SITES_BUTTON_INDEX = 0; const CANCEL_BUTTON_INDEX = 2; - const { response, checkboxChecked } = await dialog.showMessageBox( { + const { response, checkboxChecked } = await dialog.showMessageBox({ type: 'question', - message: _n( 'You have a running site', 'You have running sites', runningSiteCount ), + message: _n('You have a running site', 'You have running sites', runningSiteCount), detail: sprintf( _n( '%d site is currently running. Do you want to stop it before quitting?', @@ -464,57 +465,57 @@ async function appBoot() { ), runningSiteCount ), - buttons: [ __( 'Stop sites' ), __( 'Leave running' ), __( 'Cancel' ) ], - checkboxLabel: __( "Don't ask again" ), + buttons: [__('Stop sites'), __('Leave running'), __('Cancel')], + checkboxLabel: __("Don't ask again"), cancelId: CANCEL_BUTTON_INDEX, defaultId: STOP_SITES_BUTTON_INDEX, - } ); + }); - if ( response === CANCEL_BUTTON_INDEX ) { + if (response === CANCEL_BUTTON_INDEX) { return; } const stopSites = response === STOP_SITES_BUTTON_INDEX; - if ( checkboxChecked ) { - await updateAppdata( { stopSitesOnQuit: stopSites } ); + if (checkboxChecked) { + await updateAppdata({ stopSitesOnQuit: stopSites }); } shouldStopSitesOnQuit = stopSites; isQuittingConfirmed = true; app.quit(); - } )(); + })(); return; } - } ); + }); - app.on( 'will-quit', ( event ) => { + app.on('will-quit', (event) => { globalShortcut.unregisterAll(); stopUserDataWatcher(); stopCliEventsSubscriber(); - if ( shouldStopSitesOnQuit ) { + if (shouldStopSitesOnQuit) { event.preventDefault(); - stopAllServers( true, 6_000 ) - .then( () => { + stopAllServers(true, 6_000) + .then(() => { app.exit(); - } ) - .catch( () => { + }) + .catch(() => { app.exit(); - } ); + }); } - } ); + }); - app.on( 'activate', () => { - if ( ! finishedInitialization ) { + app.on('activate', () => { + if (!finishedInitialization) { return; } - if ( BrowserWindow.getAllWindows().length === 0 ) { + if (BrowserWindow.getAllWindows().length === 0) { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. void createMainWindow(); } - } ); + }); } From 60d8156896560f25b7b723b9f6c9200ad61fd744 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Tue, 24 Feb 2026 20:33:15 +0000 Subject: [PATCH 03/10] Fix formatting in index.ts Co-Authored-By: Claude Opus 4.6 --- apps/studio/src/index.ts | 296 +++++++++++++++++++-------------------- 1 file changed, 148 insertions(+), 148 deletions(-) diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index c8d4cce73e..96bb52910b 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -68,52 +68,52 @@ import packageJson from '../package.json'; // Helper function to get the actual URL for validation function getRendererUrl(): string { - if (!app.isPackaged && process.env['ELECTRON_RENDERER_URL']) { - return process.env['ELECTRON_RENDERER_URL']; + if ( ! app.isPackaged && process.env[ 'ELECTRON_RENDERER_URL' ] ) { + return process.env[ 'ELECTRON_RENDERER_URL' ]; } else { // For production file paths, convert to file:// URL - return pathToFileURL(path.join(__dirname, '../renderer/index.html')).href; + return pathToFileURL( path.join( __dirname, '../renderer/index.html' ) ).href; } } -if (!process.env.IS_DEV_BUILD) { - const { sentryRelease, isDevEnvironment } = getSentryReleaseInfo(app.getVersion()); +if ( ! process.env.IS_DEV_BUILD ) { + const { sentryRelease, isDevEnvironment } = getSentryReleaseInfo( app.getVersion() ); - Sentry.init({ + Sentry.init( { dsn: 'https://97693275b2716fb95048c6d12f4318cf@o248881.ingest.sentry.io/4506612776501248', debug: true, - enabled: !isDevEnvironment, + enabled: ! isDevEnvironment, release: sentryRelease, environment: isDevEnvironment ? 'development' : 'production', - }); + } ); } suppressPunycodeWarning(); -const appAppdataProvider: AppdataProvider = { +const appAppdataProvider: AppdataProvider< LastBumpStatsData > = { load: loadUserData, lock: lockAppdata, unlock: unlockAppdata, - save: async (data) => { + save: async ( data ) => { // Cast is safe: data comes from loadUserData() which returns the full UserData type. // The lock/unlock is already handled by the caller (updateLastBump in /common/lib/bump-stat.ts) // eslint-disable-next-line studio/require-lock-before-save - await saveUserData(data as never); + await saveUserData( data as never ); }, }; // Handle creating/removing shortcuts on Windows when installing/uninstalling. -const isInInstaller = require('electron-squirrel-startup'); +const isInInstaller = require( 'electron-squirrel-startup' ); // Ensure we're the only instance of the app running const gotTheLock = app.requestSingleInstanceLock(); let finishedInitialization = false; -if (gotTheLock && !isInInstaller) { +if ( gotTheLock && ! isInInstaller ) { void appBoot(); -} else if (!gotTheLock) { +} else if ( ! gotTheLock ) { app.quit(); } @@ -121,14 +121,14 @@ async function setupSentryUserId() { try { await lockAppdata(); const userData = await loadUserData(); - if (!userData.sentryUserId) { + if ( ! userData.sentryUserId ) { userData.sentryUserId = crypto.randomUUID(); } - console.log('Setting Sentry user ID:', userData.sentryUserId); - Sentry.setUser({ id: userData.sentryUserId }); + console.log( 'Setting Sentry user ID:', userData.sentryUserId ); + Sentry.setUser( { id: userData.sentryUserId } ); - await saveUserData(userData); + await saveUserData( userData ); } finally { await unlockAppdata(); } @@ -137,22 +137,22 @@ async function setupSentryUserId() { // This is a workaround to ensure that the extension background workers are started // If you are updating Electron, confirm if this is still needed // https://github.com/electron/electron/issues/41613 -function launchExtensionBackgroundWorkers(appSession = session.defaultSession) { - const extensionApi = (appSession.extensions as Electron.Extensions | undefined) || appSession; +function launchExtensionBackgroundWorkers( appSession = session.defaultSession ) { + const extensionApi = ( appSession.extensions as Electron.Extensions | undefined ) || appSession; return Promise.all( - extensionApi.getAllExtensions().map(async (extension) => { + extensionApi.getAllExtensions().map( async ( extension ) => { const manifest = extension.manifest; - if (manifest.manifest_version === 3 && manifest?.background?.service_worker) { - await appSession.serviceWorkers.startWorkerForScope(extension.url); + if ( manifest.manifest_version === 3 && manifest?.background?.service_worker ) { + await appSession.serviceWorkers.startWorkerForScope( extension.url ); } - }) + } ) ); } async function appBoot() { - app.setName(packageJson.productName); + app.setName( packageJson.productName ); - Menu.setApplicationMenu(null); + Menu.setApplicationMenu( null ); setupCustomProtocolHandler(); @@ -160,14 +160,14 @@ async function appBoot() { setupUpdates(); - if (process.defaultApp) { - if (process.argv.length >= 2) { - app.setAsDefaultProtocolClient(PROTOCOL_PREFIX, process.execPath, [ - path.resolve(process.argv[1]), - ]); + if ( process.defaultApp ) { + if ( process.argv.length >= 2 ) { + app.setAsDefaultProtocolClient( PROTOCOL_PREFIX, process.execPath, [ + path.resolve( process.argv[ 1 ] ), + ] ); } } else { - app.setAsDefaultProtocolClient(PROTOCOL_PREFIX); + app.setAsDefaultProtocolClient( PROTOCOL_PREFIX ); } // Forces all renderers to be sandboxed. IPC is the only way render processes will @@ -175,120 +175,120 @@ async function appBoot() { app.enableSandbox(); // Prevent navigation to anywhere other than known locations - app.on('web-contents-created', (_event, contents) => { - contents.on('will-navigate', (event, navigationUrl) => { - const { origin } = new URL(navigationUrl); - const allowedOrigins = [new URL(getRendererUrl()).origin]; - if (!allowedOrigins.includes(origin)) { + app.on( 'web-contents-created', ( _event, contents ) => { + contents.on( 'will-navigate', ( event, navigationUrl ) => { + const { origin } = new URL( navigationUrl ); + const allowedOrigins = [ new URL( getRendererUrl() ).origin ]; + if ( ! allowedOrigins.includes( origin ) ) { event.preventDefault(); } - }); - contents.setWindowOpenHandler(() => { + } ); + contents.setWindowOpenHandler( () => { return { action: 'deny' }; - }); - }); + } ); + } ); - function validateIpcSender(event: IpcMainInvokeEvent) { - if (!event.senderFrame) { + function validateIpcSender( event: IpcMainInvokeEvent ) { + if ( ! event.senderFrame ) { throw new Error( 'Failed IPC sender validation check: the frame has either navigated or been destroyed' ); } - if (new URL(event.senderFrame.url).origin === new URL(getRendererUrl()).origin) { + if ( new URL( event.senderFrame.url ).origin === new URL( getRendererUrl() ).origin ) { return true; } - throw new Error('Failed IPC sender validation check: ' + event.senderFrame.url); + throw new Error( 'Failed IPC sender validation check: ' + event.senderFrame.url ); } function setupIpc() { - const ipcHandlerEntries = Object.entries(ipcHandlers) as [ + const ipcHandlerEntries = Object.entries( ipcHandlers ) as [ keyof typeof ipcHandlers, - (...args: unknown[]) => unknown, + ( ...args: unknown[] ) => unknown, ][]; - for (const [key, handler] of ipcHandlerEntries) { - if (IPC_VOID_HANDLERS.find((handler) => handler === key)) { - ipcMain.on(key, function (event, ...args: unknown[]) { + for ( const [ key, handler ] of ipcHandlerEntries ) { + if ( IPC_VOID_HANDLERS.find( ( handler ) => handler === key ) ) { + ipcMain.on( key, function ( event, ...args: unknown[] ) { try { - validateIpcSender(event); - handler(event, ...args); - } catch (error) { - console.error(error); + validateIpcSender( event ); + handler( event, ...args ); + } catch ( error ) { + console.error( error ); throw error; } - }); + } ); } else { - ipcMain.handle(key, function (event, ...args: unknown[]) { + ipcMain.handle( key, function ( event, ...args: unknown[] ) { try { - validateIpcSender(event); - return handler(event, ...args); - } catch (error) { - console.error(error); + validateIpcSender( event ); + return handler( event, ...args ); + } catch ( error ) { + console.error( error ); throw error; } - }); + } ); } } } function setupCustomProtocolHandler() { - if (process.platform === 'darwin') { - app.on('open-url', (_event, url) => { - void handleDeeplink(url); - }); + if ( process.platform === 'darwin' ) { + app.on( 'open-url', ( _event, url ) => { + void handleDeeplink( url ); + } ); } else { // Handle custom protocol links on Windows and Linux - app.on('second-instance', async (_event, argv) => { - if (!finishedInitialization) { + app.on( 'second-instance', async ( _event, argv ) => { + if ( ! finishedInitialization ) { return; } const mainWindow = await getMainWindow(); // CLI commands are likely invoked from other apps, so we need to avoid changing app focus. - const isCLI = argv?.find((arg) => arg.startsWith('--cli=')); - if (!isCLI) { - if (mainWindow.isMinimized()) mainWindow.restore(); + const isCLI = argv?.find( ( arg ) => arg.startsWith( '--cli=' ) ); + if ( ! isCLI ) { + if ( mainWindow.isMinimized() ) mainWindow.restore(); mainWindow.focus(); } - const customProtocolParameter = argv?.find((arg) => arg.startsWith(PROTOCOL_PREFIX)); - if (customProtocolParameter) { - void handleDeeplink(customProtocolParameter); + const customProtocolParameter = argv?.find( ( arg ) => arg.startsWith( PROTOCOL_PREFIX ) ); + if ( customProtocolParameter ) { + void handleDeeplink( customProtocolParameter ); } - }); + } ); } } - app.on('ready', async () => { + app.on( 'ready', async () => { const locale = await getUserLocaleWithFallback(); - if (process.env.NODE_ENV === 'development') { - await installExtension(REACT_DEVELOPER_TOOLS); - await installExtension(REDUX_DEVTOOLS); + if ( process.env.NODE_ENV === 'development' ) { + await installExtension( REACT_DEVELOPER_TOOLS ); + await installExtension( REDUX_DEVTOOLS ); await launchExtensionBackgroundWorkers(); } - console.log(`App version: ${app.getVersion()}`); - console.log(`Environment: ${process.env.NODE_ENV ?? 'undefined'}`); - console.log(`Built from commit: ${COMMIT_HASH ?? 'undefined'}`); - console.log(`Local timezone: ${Intl.DateTimeFormat().resolvedOptions().timeZone}`); - console.log(`App locale: ${app.getLocale()}`); - console.log(`System locale: ${app.getSystemLocale()}`); - console.log(`Used language: ${locale}`); + console.log( `App version: ${ app.getVersion() }` ); + console.log( `Environment: ${ process.env.NODE_ENV ?? 'undefined' }` ); + console.log( `Built from commit: ${ COMMIT_HASH ?? 'undefined' }` ); + console.log( `Local timezone: ${ Intl.DateTimeFormat().resolvedOptions().timeZone }` ); + console.log( `App locale: ${ app.getLocale() }` ); + console.log( `System locale: ${ app.getSystemLocale() }` ); + console.log( `Used language: ${ locale }` ); // By default Electron automatically approves all permissions requests (e.g. notifications, webcam) // We'll opt-in to permissions we specifically need instead. - session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => { + session.defaultSession.setPermissionRequestHandler( ( webContents, permission, callback ) => { // Reject all permission requests - callback(false); - }); + callback( false ); + } ); - session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + session.defaultSession.webRequest.onHeadersReceived( ( details, callback ) => { // Only set a custom CSP header the main window UI. For other pages (like login) we should // use the CSP provided by the server, which is more likely to be up-to-date and complete. - if (details.url !== getRendererUrl()) { - callback(details); + if ( details.url !== getRendererUrl() ) { + callback( details ); return; } @@ -310,25 +310,25 @@ async function appBoot() { ]; const policies = [ ...basePolicies, - ...(process.env.NODE_ENV === 'development' ? devPolicies : prodPolicies), + ...( process.env.NODE_ENV === 'development' ? devPolicies : prodPolicies ), ]; - callback({ + callback( { ...details, responseHeaders: { ...details.responseHeaders, - 'Content-Security-Policy': [policies.filter(Boolean).join('; ')], + 'Content-Security-Policy': [ policies.filter( Boolean ).join( '; ' ) ], }, - }); - }); + } ); + } ); setupIpc(); - await setupWPServerFiles().catch(Sentry.captureException); + await setupWPServerFiles().catch( Sentry.captureException ); // WordPress server files are updated asynchronously to avoid delaying app initialization - updateWPServerFiles().catch(Sentry.captureException); + updateWPServerFiles().catch( Sentry.captureException ); - if (await needsToMigrateFromWpNowFolder()) { + if ( await needsToMigrateFromWpNowFolder() ) { await migrateFromWpNowFolder(); } @@ -345,40 +345,40 @@ async function appBoot() { const userData = await loadUserData(); // Bump stats for the first time the app runs - this is when no lastBumpStats are available - if (!userData.lastBumpStats) { - bumpStat(StatsGroup.STUDIO_APP_LAUNCH, getPlatformMetric(process.platform)); + if ( ! userData.lastBumpStats ) { + bumpStat( StatsGroup.STUDIO_APP_LAUNCH, getPlatformMetric( process.platform ) ); } // Bump a stat on each app launch, approximates total app launches - bumpStat(StatsGroup.STUDIO_APP_LAUNCH_TOTAL, getPlatformMetric(process.platform)); + bumpStat( StatsGroup.STUDIO_APP_LAUNCH_TOTAL, getPlatformMetric( process.platform ) ); // Bump stat for unique weekly app launch, approximates weekly active users bumpAggregatedUniqueStat( StatsGroup.STUDIO_APP_LAUNCH_UNIQUE, - getPlatformMetric(process.platform), + getPlatformMetric( process.platform ), 'weekly', appAppdataProvider - ).catch((err) => Sentry.captureException(err)); + ).catch( ( err ) => Sentry.captureException( err ) ); // Bump stat for unique monthly app launch, approximates monthly active users bumpAggregatedUniqueStat( StatsGroup.STUDIO_APP_LAUNCH_UNIQUE_MONTHLY, - getPlatformMetric(process.platform), + getPlatformMetric( process.platform ), 'monthly', appAppdataProvider - ).catch((err) => Sentry.captureException(err)); + ).catch( ( err ) => Sentry.captureException( err ) ); await updateWindowsCliVersionedPathIfNeeded(); finishedInitialization = true; - }); + } ); // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. - app.on('window-all-closed', () => { - if (process.platform !== 'darwin') { + app.on( 'window-all-closed', () => { + if ( process.platform !== 'darwin' ) { app.quit(); } - }); + } ); /** * We want to stop all running sites (including the process daemon) in any of these cases: @@ -389,63 +389,63 @@ async function appBoot() { let shouldStopSitesOnQuit = true; let isQuittingConfirmed = false; - app.on('before-quit', (event) => { - if (isQuittingConfirmed) { + app.on( 'before-quit', ( event ) => { + if ( isQuittingConfirmed ) { return; } - if (hasActiveSyncOperations()) { + if ( hasActiveSyncOperations() ) { const QUIT_APP_BUTTON_INDEX = 0; const CANCEL_BUTTON_INDEX = 1; - const messageInformation: Pick = + const messageInformation: Pick< MessageBoxSyncOptions, 'message' | 'detail' | 'type' > = hasUploadingPushOperations() ? { - message: __('Sync is in progress'), + message: __( 'Sync is in progress' ), detail: __( "There's a sync operation in progress. Quitting the app will abort that operation. Are you sure you want to quit?" ), type: 'warning', - } + } : { - message: __('Sync will continue'), + message: __( 'Sync will continue' ), detail: __( 'The sync process will continue running remotely after you quit Studio. We will send you an email once it is complete.' ), type: 'info', - }; + }; - const clickedButtonIndex = dialog.showMessageBoxSync({ + const clickedButtonIndex = dialog.showMessageBoxSync( { message: messageInformation.message, detail: messageInformation.detail, type: messageInformation.type, - buttons: [__('Yes, quit the app'), __('No, take me back')], + buttons: [ __( 'Yes, quit the app' ), __( 'No, take me back' ) ], cancelId: CANCEL_BUTTON_INDEX, defaultId: QUIT_APP_BUTTON_INDEX, - }); + } ); - if (clickedButtonIndex === CANCEL_BUTTON_INDEX) { + if ( clickedButtonIndex === CANCEL_BUTTON_INDEX ) { event.preventDefault(); return; } } const runningSiteCount = getRunningSiteCount(); - if (getAutoUpdaterState() !== 'waiting-for-restart' && runningSiteCount > 0) { + if ( getAutoUpdaterState() !== 'waiting-for-restart' && runningSiteCount > 0 ) { event.preventDefault(); - void (async () => { + void ( async () => { const userData = await loadUserData(); const isCliInstalled = await isStudioCliInstalled(); - if (userData.stopSitesOnQuit !== undefined) { + if ( userData.stopSitesOnQuit !== undefined ) { shouldStopSitesOnQuit = userData.stopSitesOnQuit; isQuittingConfirmed = true; app.quit(); return; } - if (!isCliInstalled || process.env.E2E) { + if ( ! isCliInstalled || process.env.E2E ) { isQuittingConfirmed = true; app.quit(); return; @@ -454,9 +454,9 @@ async function appBoot() { const STOP_SITES_BUTTON_INDEX = 0; const CANCEL_BUTTON_INDEX = 2; - const { response, checkboxChecked } = await dialog.showMessageBox({ + const { response, checkboxChecked } = await dialog.showMessageBox( { type: 'question', - message: _n('You have a running site', 'You have running sites', runningSiteCount), + message: _n( 'You have a running site', 'You have running sites', runningSiteCount ), detail: sprintf( _n( '%d site is currently running. Do you want to stop it before quitting?', @@ -465,57 +465,57 @@ async function appBoot() { ), runningSiteCount ), - buttons: [__('Stop sites'), __('Leave running'), __('Cancel')], - checkboxLabel: __("Don't ask again"), + buttons: [ __( 'Stop sites' ), __( 'Leave running' ), __( 'Cancel' ) ], + checkboxLabel: __( "Don't ask again" ), cancelId: CANCEL_BUTTON_INDEX, defaultId: STOP_SITES_BUTTON_INDEX, - }); + } ); - if (response === CANCEL_BUTTON_INDEX) { + if ( response === CANCEL_BUTTON_INDEX ) { return; } const stopSites = response === STOP_SITES_BUTTON_INDEX; - if (checkboxChecked) { - await updateAppdata({ stopSitesOnQuit: stopSites }); + if ( checkboxChecked ) { + await updateAppdata( { stopSitesOnQuit: stopSites } ); } shouldStopSitesOnQuit = stopSites; isQuittingConfirmed = true; app.quit(); - })(); + } )(); return; } - }); + } ); - app.on('will-quit', (event) => { + app.on( 'will-quit', ( event ) => { globalShortcut.unregisterAll(); stopUserDataWatcher(); stopCliEventsSubscriber(); - if (shouldStopSitesOnQuit) { + if ( shouldStopSitesOnQuit ) { event.preventDefault(); - stopAllServers(true, 6_000) - .then(() => { + stopAllServers( true, 6_000 ) + .then( () => { app.exit(); - }) - .catch(() => { + } ) + .catch( () => { app.exit(); - }); + } ); } - }); + } ); - app.on('activate', () => { - if (!finishedInitialization) { + app.on( 'activate', () => { + if ( ! finishedInitialization ) { return; } - if (BrowserWindow.getAllWindows().length === 0) { + if ( BrowserWindow.getAllWindows().length === 0 ) { // On OS X it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. void createMainWindow(); } - }); + } ); } From a362f40f8c40dc1d1092f99ea42382ca69a4804e Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Tue, 24 Feb 2026 20:41:46 +0000 Subject: [PATCH 04/10] Fix react-doctor score parsing by stripping ANSI codes first ANSI escape codes embedded in the output were preventing the grep pattern from matching the score. Strip them before parsing. Co-Authored-By: Claude Opus 4.6 --- .buildkite/commands/run-react-doctor.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.buildkite/commands/run-react-doctor.sh b/.buildkite/commands/run-react-doctor.sh index bd7c04f1cc..23883d2e6a 100755 --- a/.buildkite/commands/run-react-doctor.sh +++ b/.buildkite/commands/run-react-doctor.sh @@ -21,13 +21,14 @@ OUTPUT=$(npx -y react-doctor --no-ami --yes --fail-on error 2>&1) || DOCTOR_EXIT echo "$OUTPUT" -# Parse score from output (format: "XX / 100") -SCORE=$(echo "$OUTPUT" | grep -oE '[0-9]+ / 100' | head -1 | grep -oE '^[0-9]+') || true +# Strip ANSI escape codes for score parsing and annotation +CLEAN_OUTPUT=$(echo "$OUTPUT" | sed $'s/\x1b\\[[0-9;]*m//g') + +# Parse score from clean output (format: "XX / 100") +SCORE=$(echo "$CLEAN_OUTPUT" | grep -oE '[0-9]+ / 100' | head -1 | grep -oE '^[0-9]+') || true # Post annotation to Buildkite UI if command -v buildkite-agent &> /dev/null; then - # Strip ANSI escape codes for the annotation - CLEAN_OUTPUT=$(echo "$OUTPUT" | sed $'s/\x1b\\[[0-9;]*m//g') if [ -n "$SCORE" ]; then if [ "$SCORE" -lt "$SCORE_THRESHOLD" ]; then From a119488408698b34305ee5dd4f1342adb11d3e6f Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Tue, 24 Feb 2026 20:49:50 +0000 Subject: [PATCH 05/10] Undo unrelated formatting changes to buildkite pipeline.yml --- .buildkite/pipeline.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 1594e667de..738e9ef4e7 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -1,5 +1,6 @@ # yaml-language-server: $schema=https://raw.githubusercontent.com/buildkite/pipeline-schema/main/schema.json --- + # IMAGE_ID is an env var that only macOS agents need. # Defining it at the root level propagates it too all agents, which can seem unnecessary but is a the same time convenient and DRY. env: @@ -31,7 +32,7 @@ steps: command: bash .buildkite/commands/run-unit-tests.sh "{{matrix}}" plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] agents: - queue: '{{matrix}}' + queue: "{{matrix}}" matrix: - mac - windows @@ -51,10 +52,10 @@ steps: - test-results/**/*error-context.md plugins: [$CI_TOOLKIT_PLUGIN, $NVM_PLUGIN] agents: - queue: '{{matrix.platform}}' + queue: "{{matrix.platform}}" env: # See https://playwright.dev/docs/ci#debugging-browser-launches - DEBUG: 'pw:browser' + DEBUG: "pw:browser" matrix: setup: { platform: [], arch: [] } adjustments: @@ -67,7 +68,7 @@ steps: - github_commit_status: context: E2E Tests - - label: ':chart_with_upwards_trend: Performance Metrics' + - label: ":chart_with_upwards_trend: Performance Metrics" key: metrics agents: queue: mac @@ -82,8 +83,8 @@ steps: - github_commit_status: context: Performance Metrics - - input: '🚦 Build for Mac?' - prompt: 'Do you want to build Mac dev binaries for this PR?' + - input: "🚦 Build for Mac?" + prompt: "Do you want to build Mac dev binaries for this PR?" key: input-dev-mac if: build.tag !~ /^v[0-9]+/ && build.branch != 'trunk' @@ -136,8 +137,8 @@ steps: - x64 - arm64 - - input: '🚦 Build for Windows?' - prompt: 'Do you want to build Windows dev binaries for this PR?' + - input: "🚦 Build for Windows?" + prompt: "Do you want to build Windows dev binaries for this PR?" key: input-dev-windows if: build.tag !~ /^v[0-9]+/ && build.branch != 'trunk' @@ -160,7 +161,7 @@ steps: - x64 - arm64 - - label: ':rocket: Distribute Dev Builds' + - label: ":rocket: Distribute Dev Builds" command: | echo "--- :node: Downloading Binaries" buildkite-agent artifact download "*.app.zip" . @@ -260,7 +261,7 @@ steps: context: All Windows Release Builds if: build.tag =~ /^v[0-9]+/ - - label: ':rocket: Publish Release Builds' + - label: ":rocket: Publish Release Builds" command: | echo "--- :node: Downloading Binaries" buildkite-agent artifact download "*.zip" . From a7af3cf4e1ee63da9ffe5b31fcb20dfa0c6ae8a1 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Tue, 24 Feb 2026 20:51:23 +0000 Subject: [PATCH 06/10] Remove no-op change that was used to trigger React Doctor CI --- apps/studio/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 96bb52910b..a44d16f30c 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -1,4 +1,3 @@ -// no-op: trigger react-doctor CI step import { app, BrowserWindow, From 563710bd5b4c5c229e870958592887f047d5f472 Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Wed, 25 Feb 2026 09:20:32 +0000 Subject: [PATCH 07/10] Add react doctor rules --- apps/studio/react-doctor.config.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 apps/studio/react-doctor.config.json diff --git a/apps/studio/react-doctor.config.json b/apps/studio/react-doctor.config.json new file mode 100644 index 0000000000..9bb6d43e72 --- /dev/null +++ b/apps/studio/react-doctor.config.json @@ -0,0 +1,12 @@ +{ + "ignore": { + "rules": [ "jsx-a11y/no-autofocus" ], + "files": [ + "electron.vite.config.ts", + "forge.config.ts", + "bin/studio-cli-launcher.js", + "src/additional-phrases.ts", + "src/preload.ts" + ] + } +} From 91ca65ea950b65990ea877d66616bf3dd5ef70de Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Wed, 25 Feb 2026 09:21:08 +0000 Subject: [PATCH 08/10] Reduce SCORE_THRESHOLD to 90 --- .buildkite/commands/run-react-doctor.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/commands/run-react-doctor.sh b/.buildkite/commands/run-react-doctor.sh index 23883d2e6a..cd94588f36 100755 --- a/.buildkite/commands/run-react-doctor.sh +++ b/.buildkite/commands/run-react-doctor.sh @@ -11,7 +11,7 @@ bash .buildkite/commands/install-node-dependencies.sh echo '--- :react: React Doctor' -SCORE_THRESHOLD=95 +SCORE_THRESHOLD=90 DOCTOR_EXIT=0 # Run react-doctor and capture output From d5030d66fac1e1a7d158403648d7a077f8e0545c Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Wed, 25 Feb 2026 09:32:20 +0000 Subject: [PATCH 09/10] Reduce threshold to 87 --- .buildkite/commands/run-react-doctor.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.buildkite/commands/run-react-doctor.sh b/.buildkite/commands/run-react-doctor.sh index cd94588f36..2d1c7e6d86 100755 --- a/.buildkite/commands/run-react-doctor.sh +++ b/.buildkite/commands/run-react-doctor.sh @@ -11,7 +11,7 @@ bash .buildkite/commands/install-node-dependencies.sh echo '--- :react: React Doctor' -SCORE_THRESHOLD=90 +SCORE_THRESHOLD=87 DOCTOR_EXIT=0 # Run react-doctor and capture output From d59f6e0a0d881a98a88d85c7023bd116a04712db Mon Sep 17 00:00:00 2001 From: Antonio Sejas Date: Wed, 25 Feb 2026 09:33:14 +0000 Subject: [PATCH 10/10] Remove unused files and test function --- .../e2e/page-objects/user-settings-modal.ts | 56 --- apps/studio/src/lib/test-utils.tsx | 11 - .../tests/convert-tree-to-options-to-sync.tsx | 451 ------------------ apps/studio/src/tests/utils/style-mock.js | 1 - 4 files changed, 519 deletions(-) delete mode 100644 apps/studio/e2e/page-objects/user-settings-modal.ts delete mode 100644 apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx delete mode 100644 apps/studio/src/tests/utils/style-mock.js diff --git a/apps/studio/e2e/page-objects/user-settings-modal.ts b/apps/studio/e2e/page-objects/user-settings-modal.ts deleted file mode 100644 index ce33dfcd06..0000000000 --- a/apps/studio/e2e/page-objects/user-settings-modal.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type Page, expect } from '@playwright/test'; - -export default class UserSettingsModal { - constructor( private page: Page ) {} - - get locator() { - return this.page.getByRole( 'dialog', { name: 'Settings' } ); - } - - get preferencesTab() { - return this.locator.getByRole( 'tab', { name: 'Preferences' } ); - } - - get accountTab() { - return this.locator.getByRole( 'tab', { name: 'Account' } ); - } - - get usageTab() { - return this.locator.getByRole( 'tab', { name: 'Usage' } ); - } - - get languageSelect() { - return this.page.getByTestId( 'language-select' ); - } - - get saveButton() { - return this.page.getByTestId( 'preferences-save-button' ); - } - - get cancelButton() { - return this.page.getByTestId( 'preferences-cancel-button' ); - } - - get closeButton() { - return this.locator.getByRole( 'button', { name: 'Close' } ); - } - - async selectLanguage( language: string ) { - await this.languageSelect.selectOption( { label: language } ); - } - - async save() { - await this.saveButton.click(); - await expect( this.locator ).not.toBeVisible(); - } - - async cancel() { - await this.cancelButton.click(); - await expect( this.locator ).not.toBeVisible(); - } - - async close() { - await this.closeButton.click(); - await expect( this.locator ).not.toBeVisible(); - } -} diff --git a/apps/studio/src/lib/test-utils.tsx b/apps/studio/src/lib/test-utils.tsx index e17a98c5d8..976599202d 100644 --- a/apps/studio/src/lib/test-utils.tsx +++ b/apps/studio/src/lib/test-utils.tsx @@ -1,7 +1,4 @@ import { configureStore } from '@reduxjs/toolkit'; -import { render } from '@testing-library/react'; -import React from 'react'; -import { Provider } from 'react-redux'; import { rootReducer } from 'src/stores'; import { appVersionApi } from 'src/stores/app-version-api'; import { certificateTrustApi } from 'src/stores/certificate-trust-api'; @@ -43,11 +40,3 @@ export function createTestStore( options: TestStoreOptions = {} ) { return store; } - -export function renderWithProvider( ui: React.ReactElement, options: TestStoreOptions = {} ) { - const store = createTestStore( options ); - return { - ...render( { ui } ), - store, - }; -} diff --git a/apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx b/apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx deleted file mode 100644 index 4562772572..0000000000 --- a/apps/studio/src/modules/sync/tests/convert-tree-to-options-to-sync.tsx +++ /dev/null @@ -1,451 +0,0 @@ -import { TreeNode } from 'src/components/tree-view'; -import { SYNC_OPTIONS } from 'src/constants'; -import { convertTreeToPushOptions } from 'src/modules/sync/lib/convert-tree-to-sync-options'; - -// Helper to create a basic tree structure for testing with new file tree format -const createBaseTree = (): TreeNode[] => { - return [ - { - id: 'filesAndFolders', - name: 'filesAndFolders', - label: 'Files and folders', - checked: false, - indeterminate: false, - expanded: false, - hideExpandButton: true, - children: [ - { - id: 'wp-content', - name: 'wp-content', - label: 'wp-content', - checked: false, - indeterminate: false, - type: 'folder', - children: [], - }, - ], - }, - { - id: SYNC_OPTIONS.sqls, - name: SYNC_OPTIONS.sqls, - label: 'Database', - checked: false, - }, - ]; -}; - -describe( 'convertTreeToPushOptions', () => { - it( 'returns ["all"] when all options are selected', () => { - const tree = createBaseTree(); - tree[ 0 ].checked = true; // filesAndFolders - tree[ 1 ].checked = true; // sqls - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { optionsToSync: [ 'all' ] } ); - } ); - - it( 'returns ["sqls"] when only database is selected', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls only - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { optionsToSync: [ 'sqls' ] } ); - } ); - - it( 'returns ["plugins"] when only plugins are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'my-plugin', - name: 'my-plugin', - label: 'My Plugin', - checked: true, - type: 'folder', - path: 'wp-content/plugins/my-plugin', - pathId: 'plugins/my-plugin', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins' ], - specificSelectionPaths: [ 'plugins/my-plugin' ], - } ); - } ); - - it( 'returns ["uploads"] when only uploads are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: '2024-folder', - name: '2024', - label: '2024', - checked: true, - type: 'folder', - path: 'wp-content/uploads/2024', - pathId: 'uploads/2024', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'uploads' ], - specificSelectionPaths: [ 'uploads/2024' ], - } ); - } ); - - it( 'returns ["sqls", "plugins"] when both are selected', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'my-plugin', - name: 'my-plugin', - label: 'My Plugin', - checked: true, - type: 'folder', - path: 'wp-content/plugins/my-plugin', - pathId: 'plugins/my-plugin', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'sqls', 'plugins' ], - specificSelectionPaths: [ 'plugins/my-plugin' ], - } ); - } ); - - it( 'returns ["plugins", "themes", "uploads", "contents"] when "All files and folders" is selected', () => { - const tree = createBaseTree(); - tree[ 0 ].checked = true; // filesAndFolders - tree[ 1 ].checked = false; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.checked = true; - wpContentNode.children = [ - { - id: 'local-wp-content/fonts', - name: 'fonts', - label: 'fonts', - checked: true, - type: 'folder', - path: 'wp-content/fonts', - pathId: 'wp-content/fonts', - children: [], - }, - { - id: 'local-wp-content/mu-plugins', - name: 'mu-plugins', - label: 'mu-plugins', - checked: true, - type: 'folder', - path: 'wp-content/mu-plugins', - pathId: 'wp-content/mu-plugins', - children: [], - }, - { - id: 'local-wp-content/plugins', - name: 'plugins', - label: 'plugins', - checked: true, - type: 'folder', - path: 'wp-content/plugins', - pathId: 'wp-content/plugins', - children: [ - { - id: 'local-wp-content/plugins/akismet', - name: 'akismet', - label: 'akismet', - checked: true, - type: 'plugin', - path: 'wp-content/plugins/akismet', - pathId: 'wp-content/plugins/akismet', - children: [], - }, - ], - indeterminate: false, - }, - { - id: 'local-wp-content/themes', - name: 'themes', - label: 'themes', - checked: true, - type: 'folder', - path: 'wp-content/themes', - pathId: 'wp-content/themes', - children: [ - { - id: 'local-wp-content/themes/twentytwentyfive', - name: 'twentytwentyfive', - label: 'twentytwentyfive', - checked: true, - type: 'theme', - path: 'wp-content/themes/twentytwentyfive', - pathId: 'wp-content/themes/twentytwentyfive', - children: [], - }, - ], - indeterminate: false, - }, - { - id: 'local-wp-content/uploads', - name: 'uploads', - label: 'uploads', - checked: true, - type: 'folder', - path: 'wp-content/uploads', - pathId: 'wp-content/uploads', - children: [ - { - id: 'local-wp-content/uploads/2025', - name: '2025', - label: '2025', - checked: true, - type: 'folder', - path: 'wp-content/uploads/2025', - pathId: 'wp-content/uploads/2025', - children: [], - indeterminate: false, - }, - ], - indeterminate: false, - }, - { - id: 'local-wp-content/index-php', - name: 'index.php', - label: 'index.php', - checked: true, - type: 'file', - path: 'wp-content/index.php', - pathId: 'wp-content/index.php', - }, - ]; - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'contents', 'plugins', 'themes', 'uploads' ], - specificSelectionPaths: [ - 'fonts', - 'mu-plugins', - 'plugins', - 'themes', - 'uploads', - 'index.php', - ], - } ); - } ); - - describe( 'partial selections', () => { - it( 'returns partial plugins selection when only some plugins are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - { - id: 'plugin3', - name: 'plugin3', - label: 'Plugin 3', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin3', - pathId: 'plugins/plugin3', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins' ], - specificSelectionPaths: [ 'plugins/plugin1', 'plugins/plugin3' ], - } ); - } ); - - it( 'returns partial themes selection when only some themes are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'theme1', - name: 'theme1', - label: 'Theme 1', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme1', - pathId: 'themes/theme1', - }, - { - id: 'theme3', - name: 'theme3', - label: 'Theme 3', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme3', - pathId: 'themes/theme3', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'themes' ], - specificSelectionPaths: [ 'themes/theme1', 'themes/theme3' ], - } ); - } ); - - it( 'returns partial uploads selection when only some uploads are selected', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'upload1', - name: 'upload1', - label: 'Upload 1', - checked: true, - type: 'folder', - path: 'wp-content/uploads/upload1', - pathId: 'uploads/upload1', - }, - { - id: 'upload2', - name: 'upload2', - label: 'Upload 2', - checked: true, - type: 'folder', - path: 'wp-content/uploads/upload2', - pathId: 'uploads/upload2', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'uploads' ], - specificSelectionPaths: [ 'uploads/upload1', 'uploads/upload2' ], - } ); - } ); - - it( 'returns mixed partial selections for plugins and themes', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - { - id: 'theme1', - name: 'theme1', - label: 'Theme 1', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme1', - pathId: 'themes/theme1', - }, - { - id: 'theme3', - name: 'theme3', - label: 'Theme 3', - checked: true, - type: 'folder', - path: 'wp-content/themes/theme3', - pathId: 'themes/theme3', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins', 'themes' ], - specificSelectionPaths: [ 'plugins/plugin1', 'themes/theme1', 'themes/theme3' ], - } ); - } ); - - it( 'returns no specificSelections when all children are selected in a category', () => { - const tree = createBaseTree(); - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - { - id: 'plugin2', - name: 'plugin2', - label: 'Plugin 2', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin2', - pathId: 'plugins/plugin2', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'plugins' ], - specificSelectionPaths: [ 'plugins/plugin1', 'plugins/plugin2' ], - } ); - } ); - - it( 'handles mixed selection with database and partial plugins', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'plugin1', - name: 'plugin1', - label: 'Plugin 1', - checked: true, - type: 'folder', - path: 'wp-content/plugins/plugin1', - pathId: 'plugins/plugin1', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'sqls', 'plugins' ], - specificSelectionPaths: [ 'plugins/plugin1' ], - } ); - } ); - } ); - - it( 'strips folder type prefix from specific selections', () => { - const tree = createBaseTree(); - tree[ 1 ].checked = true; // sqls - const wpContentNode = tree[ 0 ].children![ 0 ]; - wpContentNode.children = [ - { - id: 'my-plugin', - name: 'my-plugin', - label: 'My Plugin', - checked: true, - type: 'folder', - path: 'wp-content/plugins/my-plugin', - pathId: 'plugins/my-plugin', - }, - ]; - - const optionsToSync = convertTreeToPushOptions( tree ); - expect( optionsToSync ).toEqual( { - optionsToSync: [ 'sqls', 'plugins' ], - specificSelectionPaths: [ 'plugins/my-plugin' ], - } ); - } ); -} ); diff --git a/apps/studio/src/tests/utils/style-mock.js b/apps/studio/src/tests/utils/style-mock.js deleted file mode 100644 index f053ebf797..0000000000 --- a/apps/studio/src/tests/utils/style-mock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {};