Skip to content

Commit

Permalink
feat: Automatic sqlite-database-integration upgrade (#136)
Browse files Browse the repository at this point in the history
* feat: Download latest sqlite-databse-integration release tag

Improve stability and enable version comparisons for future upgrades.

* feat: Starting the app updates sqlite-database-integration installation

Ensure new sites receive the latest sqlite-database-integration fixes
and improvements.

* feat: Starting a site updates an outdated sqlite-integration-plugin

Ensure existing sites receive the latest fixes and features.

* fix: Avoid unnecessary sqlite-database-integration upgrades

If version comparison fails, assume the installed version is the latest.
For example, fetching the latest version while offline will result in a
comparison failure.

* feat: Cache sqlite-database-integration versions for app session

Avoid repeatedly fetching the latest versions each time a site starts.

* refactor: Remove unused import

* refactor: Remove unused references to specific past release versions

We only ever install the latest version, so we do not need the
unnecessary complexity of fetching past versions. Also, this allows us
to use the same download source the `sqlite-database-integration` plugin
throughout the source.

* refactor: Remove `-main` branch suffix from SQLite install

The `-main` suffix is no longer accurate now that we rely upon release
tags rather than downloading the latest development branch.
  • Loading branch information
dcalhoun authored May 21, 2024
1 parent b9642d8 commit d296d88
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 13 deletions.
2 changes: 1 addition & 1 deletion scripts/download-wp-server-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const FILES_TO_DOWNLOAD = [
{
name: 'sqlite',
description: 'SQLite files',
url: 'https://codeload.github.com/WordPress/sqlite-database-integration/zip/refs/heads/main',
url: 'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip',
},
];

Expand Down
10 changes: 10 additions & 0 deletions src/__mocks__/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ fs.__setFileContents = ( path: string, fileContents: string | string[] ) => {
}
);

( fs.readFileSync as jest.Mock ).mockImplementation( ( path: string ): string => {
const fileContents = mockFiles[ path ];

if ( typeof fileContents === 'string' ) {
return fileContents;
}

return '';
} );

( fs.promises.readdir as jest.Mock ).mockImplementation(
async ( path: string ): Promise< Array< string > > => {
const dirContents = mockFiles[ path ];
Expand Down
16 changes: 15 additions & 1 deletion src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import { createPassword } from './lib/passwords';
import { phpGetThemeDetails } from './lib/php-get-theme-details';
import { sanitizeForLogging } from './lib/sanitize-for-logging';
import { sortSites } from './lib/sort-sites';
import {
isSqliteInstallationOutdated,
removeLegacySqliteIntegrationPlugin,
} from './lib/sqlite-versions';
import { writeLogToFile, type LogLevel } from './logging';
import { SiteServer, createSiteWorkingDirectory } from './site-server';
import { DEFAULT_SITE_PATH, getServerFilesPath, getSiteThumbnailPath } from './storage/paths';
Expand Down Expand Up @@ -105,7 +109,9 @@ async function setupSqliteIntegration( path: string ) {
)
);
const sqlitePluginPath = nodePath.join( wpContentPath, 'mu-plugins', SQLITE_FILENAME );
await copySync( nodePath.join( getServerFilesPath(), SQLITE_FILENAME ), sqlitePluginPath );
copySync( nodePath.join( getServerFilesPath(), SQLITE_FILENAME ), sqlitePluginPath );

await removeLegacySqliteIntegrationPlugin( sqlitePluginPath );
}

export async function createSite(
Expand Down Expand Up @@ -216,6 +222,14 @@ export async function startServer(
return null;
}

if (
await isSqliteInstallationOutdated(
`${ server.details.path }/wp-content/mu-plugins/${ SQLITE_FILENAME }`
)
) {
await setupSqliteIntegration( server.details.path );
}

const parentWindow = BrowserWindow.fromWebContents( event.sender );
await server.start();
if ( parentWindow && ! parentWindow.isDestroyed() && ! event.sender.isDestroyed() ) {
Expand Down
94 changes: 94 additions & 0 deletions src/lib/sqlite-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import path from 'path';
import * as Sentry from '@sentry/electron/main';
import fs from 'fs-extra';
import semver from 'semver';
import { downloadSqliteIntegrationPlugin } from '../../vendor/wp-now/src/download';
import getSqlitePath from '../../vendor/wp-now/src/get-sqlite-path';

export async function updateLatestSqliteVersion() {
let shouldOverwrite = false;
const installedPath = getSqlitePath();
const installedFiles = ( await fs.pathExists( installedPath ) )
? await fs.readdir( installedPath )
: [];
if ( installedFiles.length !== 0 ) {
shouldOverwrite = await isSqliteInstallationOutdated( installedPath );
}

await downloadSqliteIntegrationPlugin( { overwrite: shouldOverwrite } );

await removeLegacySqliteIntegrationPlugin( installedPath );
}

export async function isSqliteInstallationOutdated( installationPath: string ): Promise< boolean > {
const installedVersion = getSqliteVersionFromInstallation( installationPath );
const latestVersion = await getLatestSqliteVersion();

if ( ! installedVersion ) {
return true;
}

if ( ! latestVersion ) {
return false;
}

try {
return semver.lt( installedVersion, latestVersion );
} catch ( _error ) {
return false;
}
}

function getSqliteVersionFromInstallation( installationPath: string ): string {
let versionFileContent = '';
try {
versionFileContent = fs.readFileSync( path.join( installationPath, 'load.php' ), 'utf8' );
} catch ( err ) {
return '';
}
const matches = versionFileContent.match( /\s\*\sVersion:\s*([0-9a-zA-Z.-]+)/ );
return matches?.[ 1 ] || '';
}

let latestSqliteVersionsCache: string | null = null;

async function getLatestSqliteVersion() {
// Only fetch the latest version once per app session
if ( latestSqliteVersionsCache ) {
return latestSqliteVersionsCache;
}

try {
const response = await fetch(
'https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&slug=sqlite-database-integration'
);
const data: Record< string, string > = await response.json();
latestSqliteVersionsCache = data.version;
} catch ( _error ) {
// Discard the failed fetch, return the cache
}

return latestSqliteVersionsCache;
}

/**
* Removes legacy `sqlite-integration-plugin` installations from the specified
* installation path that including a `-main` branch suffix.
*
* @param installPath - The path where the plugin is installed.
*
* @returns A promise that resolves when the plugin is successfully removed.
*
* @todo Remove this function after a few releases.
*/
export async function removeLegacySqliteIntegrationPlugin( installPath: string ) {
try {
const legacySqlitePluginPath = `${ installPath }-main`;
if ( await fs.pathExists( legacySqlitePluginPath ) ) {
await fs.remove( legacySqlitePluginPath );
}
} catch ( error ) {
// If the removal fails, log the error but don't throw
Sentry.captureException( error );
}
}
2 changes: 2 additions & 0 deletions src/setup-wp-server-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SQLITE_FILENAME } from '../vendor/wp-now/src/constants';
import { getWordPressVersionPath } from '../vendor/wp-now/src/download';
import getSqlitePath from '../vendor/wp-now/src/get-sqlite-path';
import { recursiveCopyDirectory } from './lib/fs-utils';
import { updateLatestSqliteVersion } from './lib/sqlite-versions';
import {
getWordPressVersionFromInstallation,
updateLatestWordPressVersion,
Expand Down Expand Up @@ -51,4 +52,5 @@ export default async function setupWPServerFiles() {
await copyBundledLatestWPVersion();
await copyBundledSqlite();
await updateLatestWordPressVersion();
await updateLatestSqliteVersion();
}
62 changes: 57 additions & 5 deletions src/tests/ipc-handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
*/
import { shell, IpcMainInvokeEvent } from 'electron';
import fs from 'fs';
import { createSite } from '../ipc-handlers';
import { copySync } from 'fs-extra';
import { SQLITE_FILENAME } from '../../vendor/wp-now/src/constants';
import { downloadSqliteIntegrationPlugin } from '../../vendor/wp-now/src/download';
import { createSite, startServer } from '../ipc-handlers';
import { isEmptyDir, pathExists } from '../lib/fs-utils';
import { isSqliteInstallationOutdated } from '../lib/sqlite-versions';
import { SiteServer, createSiteWorkingDirectory } from '../site-server';

jest.mock( 'fs' );
jest.mock( 'fs-extra' );
jest.mock( '../lib/fs-utils' );
jest.mock( '../site-server' );
jest.mock( '../lib/sqlite-versions' );
jest.mock( '../../vendor/wp-now/src/download' );

( SiteServer.create as jest.Mock ).mockImplementation( ( details ) => ( {
start: jest.fn(),
Expand All @@ -36,10 +43,15 @@ const mockIpcMainInvokeEvent = {
// Double assert the type with `unknown` to simplify mocking this value
} as unknown as IpcMainInvokeEvent;

afterEach( () => {
jest.clearAllMocks();
} );

describe( 'createSite', () => {
it( 'should create a site', async () => {
( isEmptyDir as jest.Mock ).mockResolvedValue( true );
( pathExists as jest.Mock ).mockResolvedValue( true );
( isEmptyDir as jest.Mock ).mockResolvedValueOnce( true );
( pathExists as jest.Mock ).mockResolvedValueOnce( true );

const [ site ] = await createSite( mockIpcMainInvokeEvent, '/test', 'Test' );

expect( site ).toEqual( {
Expand All @@ -53,8 +65,8 @@ describe( 'createSite', () => {

describe( 'when the site path started as an empty directory', () => {
it( 'should reset the directory when site creation fails', () => {
( isEmptyDir as jest.Mock ).mockResolvedValue( true );
( pathExists as jest.Mock ).mockResolvedValue( true );
( isEmptyDir as jest.Mock ).mockResolvedValueOnce( true );
( pathExists as jest.Mock ).mockResolvedValueOnce( true );
( createSiteWorkingDirectory as jest.Mock ).mockImplementation( () => {
throw new Error( 'Intentional test error' );
} );
Expand All @@ -66,3 +78,43 @@ describe( 'createSite', () => {
} );
} );
} );

describe( 'startServer', () => {
describe( 'when sqlite-database-integration plugin is outdated', () => {
it( 'should update sqlite-database-integration plugin', async () => {
const mockSitePath = 'mock-site-path';
( isSqliteInstallationOutdated as jest.Mock ).mockResolvedValue( true );
( SiteServer.get as jest.Mock ).mockReturnValue( {
details: { path: mockSitePath },
start: jest.fn(),
updateSiteDetails: jest.fn(),
updateCachedThumbnail: jest.fn( () => Promise.resolve() ),
} );

await startServer( mockIpcMainInvokeEvent, 'mock-site-id' );

expect( downloadSqliteIntegrationPlugin ).toHaveBeenCalledTimes( 1 );
expect( copySync ).toHaveBeenCalledWith(
`/path/to/app/appData/App Name/server-files/sqlite-database-integration`,
`${ mockSitePath }/wp-content/mu-plugins/${ SQLITE_FILENAME }`
);
} );
} );

describe( 'when sqlite-database-integration plugin is up-to-date', () => {
it( 'should not update sqlite-database-integration plugin', async () => {
( isSqliteInstallationOutdated as jest.Mock ).mockResolvedValue( false );
( SiteServer.get as jest.Mock ).mockReturnValue( {
details: { path: 'mock-site-path' },
start: jest.fn(),
updateSiteDetails: jest.fn(),
updateCachedThumbnail: jest.fn( () => Promise.resolve() ),
} );

await startServer( mockIpcMainInvokeEvent, 'mock-site-id' );

expect( downloadSqliteIntegrationPlugin ).not.toHaveBeenCalled();
expect( copySync ).not.toHaveBeenCalled();
} );
} );
} );
4 changes: 2 additions & 2 deletions vendor/wp-now/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* The file name for the SQLite plugin name.
*/
export const SQLITE_FILENAME = 'sqlite-database-integration-main';
export const SQLITE_FILENAME = 'sqlite-database-integration';

/**
* The URL for downloading the "SQLite database integration" WordPress Plugin.
*/
export const SQLITE_URL =
'https://github.com/WordPress/sqlite-database-integration/archive/refs/heads/main.zip';
'https://downloads.wordpress.org/plugin/sqlite-database-integration.zip';

/**
* The default starting port for running the WP Now server.
Expand Down
12 changes: 8 additions & 4 deletions vendor/wp-now/src/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,22 @@ export async function downloadWordPress(
}
}

export async function downloadSqliteIntegrationPlugin() {
export async function downloadSqliteIntegrationPlugin(
{overwrite}: {overwrite: boolean} = {overwrite: false}
) {
const finalFolder = getSqlitePath();
const tempFolder = path.join(os.tmpdir(), SQLITE_FILENAME);
const { downloaded, statusCode } = await downloadFileAndUnzip({
url: SQLITE_URL,
destinationFolder: tempFolder,
checkFinalPath: finalFolder,
itemName: 'SQLite',
overwrite,
});
if (downloaded) {
fs.ensureDirSync(path.dirname(finalFolder));
fs.moveSync(tempFolder, finalFolder, {
const nestedFolder = path.join(tempFolder, SQLITE_FILENAME);
await fs.ensureDir(path.dirname(finalFolder));
await fs.move(nestedFolder, finalFolder, {
overwrite: true,
});
} else if(0 !== statusCode) {
Expand Down Expand Up @@ -318,4 +322,4 @@ set_error_handler(function($severity, $message, $file, $line) {

export function getWordPressVersionPath(wpVersion: string) {
return path.join(getWordpressVersionsPath(), wpVersion);
}
}

0 comments on commit d296d88

Please sign in to comment.