Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Automatic sqlite-database-integration upgrade #136

Merged
merged 8 commits into from
May 21, 2024
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',
dcalhoun marked this conversation as resolved.
Show resolved Hide resolved
},
];

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
11 changes: 10 additions & 1 deletion src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ 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 } 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 +106,7 @@ async function setupSqliteIntegration( path: string ) {
)
);
const sqlitePluginPath = nodePath.join( wpContentPath, 'mu-plugins', SQLITE_FILENAME );
await copySync( nodePath.join( getServerFilesPath(), SQLITE_FILENAME ), sqlitePluginPath );
dcalhoun marked this conversation as resolved.
Show resolved Hide resolved
copySync( nodePath.join( getServerFilesPath(), SQLITE_FILENAME ), sqlitePluginPath );
}

export async function createSite(
Expand Down Expand Up @@ -216,6 +217,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
60 changes: 60 additions & 0 deletions src/lib/sqlite-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import path from 'path';
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 } );
}

export async function isSqliteInstallationOutdated( installationPath: string ): Promise< boolean > {
try {
const installedVersion = getSqliteVersionFromInstallation( installationPath );
const latestVersion = await getLatestSqliteVersion();
return latestVersion ? semver.lt( installedVersion, latestVersion ) : false;
} catch ( _error ) {
return false;
}
}

function getSqliteVersionFromInstallation( installationPath: string ): string {
let versionFileContent = '';
try {
versionFileContent = fs.readFileSync( path.join( installationPath, 'load.php' ), 'utf8' );
Copy link
Contributor

Choose a reason for hiding this comment

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

In general, we try to avoid using the sync version of FS functions to prevent blocking the main thread. I wonder if we could use the asynchronous one.

} 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;
}
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();
dcalhoun marked this conversation as resolved.
Show resolved Hide resolved
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-main`,
`${ 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();
} );
} );
} );
2 changes: 1 addition & 1 deletion vendor/wp-now/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const SQLITE_FILENAME = 'sqlite-database-integration-main';
* 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
14 changes: 10 additions & 4 deletions vendor/wp-now/src/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,24 @@ 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, {
// Relocate files from the nested folder lacking the `-main` branch suffix
// now that we install release tags instead of the main branch.
const nestedFolder = path.join(tempFolder, SQLITE_FILENAME.replace('-main', ''));
await fs.ensureDir(path.dirname(finalFolder));
await fs.move(nestedFolder, finalFolder, {
overwrite: true,
});
} else if(0 !== statusCode) {
Expand Down Expand Up @@ -318,4 +324,4 @@ set_error_handler(function($severity, $message, $file, $line) {

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