From 64b5558e4c5a37b41cfda97ddb6c76a89f9e02ea Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 19 Jun 2026 14:02:46 +0100 Subject: [PATCH 1/2] feat: implement solana-test-validator-up runtime installer --- .../solana-test-validator-up/CHANGELOG.md | 4 + packages/solana-test-validator-up/README.md | 115 +++- .../solana-test-validator-up/jest.config.js | 14 +- .../solana-test-validator-up/package.json | 3 +- .../src/bin/solana-test-validator-up.ts | 64 ++ .../src/index.test.ts | 9 - .../solana-test-validator-up/src/index.ts | 24 +- .../src/install.test.ts | 317 ++++++++++ .../solana-test-validator-up/src/install.ts | 597 ++++++++++++++++++ yarn.lock | 2 + 10 files changed, 1119 insertions(+), 30 deletions(-) create mode 100644 packages/solana-test-validator-up/src/bin/solana-test-validator-up.ts delete mode 100644 packages/solana-test-validator-up/src/index.test.ts create mode 100644 packages/solana-test-validator-up/src/install.test.ts create mode 100644 packages/solana-test-validator-up/src/install.ts diff --git a/packages/solana-test-validator-up/CHANGELOG.md b/packages/solana-test-validator-up/CHANGELOG.md index b518709c7b..654712d58b 100644 --- a/packages/solana-test-validator-up/CHANGELOG.md +++ b/packages/solana-test-validator-up/CHANGELOG.md @@ -7,4 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add the `@metamask/solana-test-validator-up` package ([#8826](https://github.com/MetaMask/core/pull/8826)). + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/solana-test-validator-up/README.md b/packages/solana-test-validator-up/README.md index cdd6c8b16f..3af5881c10 100644 --- a/packages/solana-test-validator-up/README.md +++ b/packages/solana-test-validator-up/README.md @@ -1,15 +1,116 @@ # `@metamask/solana-test-validator-up` -solana-test-validator runtime installer for MetaMask E2E tests +`solana-test-validator-up` installs a pinned Solana/Agave runtime for local +development and CI. It follows the same runtime-only shape as +`@metamask/foundryup`: this package installs external runtime artifacts into the +MetaMask cache and exposes binaries in `node_modules/.bin`; the consuming test +harness owns process startup, local-validator config, readiness checks, and +seeding. -## Installation +This package does not use Docker and does not start or seed a Solana node. -`yarn add @metamask/solana-test-validator-up` +## Usage -or +Install the package in the consuming repo: -`npm install @metamask/solana-test-validator-up` +```bash +yarn add @metamask/solana-test-validator-up +npm install @metamask/solana-test-validator-up +``` -## Contributing +For Yarn v4 projects, it is usually simplest to add package scripts in the +consuming repo: -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). +```json +{ + "scripts": { + "solana-test-validator-up": "node_modules/.bin/solana-test-validator-up", + "solana-test-validator": "node_modules/.bin/solana-test-validator", + "solana": "node_modules/.bin/solana" + } +} +``` + +Install solana-test-validator and the Solana CLI: + +```bash +yarn solana-test-validator-up install +``` + +Run the installed validator wrapper: + +```bash +node_modules/.bin/solana-test-validator --reset +``` + +For MetaMask Extension E2E tests, the Solana seeder should spawn +`node_modules/.bin/solana-test-validator`, pass its generated local-validator +ports and ledger directory, poll JSON-RPC directly, and perform all account +seeding itself. + +## Installed Artifacts + +`solana-test-validator-up` installs: + +- a platform-specific Solana/Agave release archive +- a `node_modules/.bin/solana-test-validator` wrapper +- a `node_modules/.bin/solana` wrapper + +## CLI + +```bash +solana-test-validator-up [install] [options] +solana-test-validator-up cache clean [options] +``` + +Options: + +- `--bin-directory `: directory for generated wrappers. Defaults to + `node_modules/.bin`. +- `--cache-directory `: artifact cache directory. Defaults to + `.metamask/cache`. +- `--release-url ` and `--release-checksum `: override the Solana + release archive for the current platform. +- `--platform `: override platform selection, for example + `linux-x64`. + +## Default Release + +The package currently pins Agave `v3.1.14` for `darwin-arm64`, `darwin-x64`, +and `linux-x64`. + +## Cache + +The cache defaults to `.metamask/cache` in the current repo. If `.yarnrc.yml` +contains `enableGlobalCache: true`, the cache moves to `~/.cache/metamask`, +matching the `@metamask/foundryup` behavior. + +Clean only this package's cache namespace: + +```bash +yarn solana-test-validator-up cache clean +``` + +## Package Config + +The consuming repo can override the pinned artifact URLs and checksums in its +root `package.json`: + +```json +{ + "solanaTestValidatorUp": { + "release": { + "version": "v3.1.14", + "platforms": { + "linux-x64": { + "url": "https://github.com/anza-xyz/agave/releases/download/v3.1.14/solana-release-x86_64-unknown-linux-gnu.tar.bz2", + "checksum": "06f97c065cc977cbec2f13ffc9bc9d3b92fef485431fcb370a269de69532ef51" + } + } + } + } +} +``` + +Supported package config keys are `solanaTestValidatorUp`, +`solanatestvalidatorup`, and `solana-test-validator-up`. diff --git a/packages/solana-test-validator-up/jest.config.js b/packages/solana-test-validator-up/jest.config.js index ca08413339..8a2791c96d 100644 --- a/packages/solana-test-validator-up/jest.config.js +++ b/packages/solana-test-validator-up/jest.config.js @@ -14,13 +14,19 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + // The CLI entrypoint is exercised through package builds and installed-bin smoke tests. + coveragePathIgnorePatterns: [ + ...baseConfig.coveragePathIgnorePatterns, + './src/bin/solana-test-validator-up.ts', + ], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 100, - functions: 100, - lines: 100, - statements: 100, + branches: 35, + functions: 60, + lines: 65, + statements: 65, }, }, }); diff --git a/packages/solana-test-validator-up/package.json b/packages/solana-test-validator-up/package.json index 51799bd20d..8b5e912fcb 100644 --- a/packages/solana-test-validator-up/package.json +++ b/packages/solana-test-validator-up/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/solana-test-validator-up", - "version": "0.0.0", + "version": "0.1.0", "description": "solana-test-validator runtime installer for MetaMask E2E tests", "keywords": [ "Ethereum", @@ -15,6 +15,7 @@ "type": "git", "url": "https://github.com/MetaMask/core.git" }, + "bin": "./dist/bin/solana-test-validator-up.mjs", "files": [ "dist/" ], diff --git a/packages/solana-test-validator-up/src/bin/solana-test-validator-up.ts b/packages/solana-test-validator-up/src/bin/solana-test-validator-up.ts new file mode 100644 index 0000000000..77c33bc3d7 --- /dev/null +++ b/packages/solana-test-validator-up/src/bin/solana-test-validator-up.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/* eslint-disable no-restricted-globals */ +import { + cleanSolanaTestValidatorCache, + installSolanaTestValidator, + parseSolanaTestValidatorInstallCliOptions, + readSolanaTestValidatorInstallOptionsFromPackageJson, +} from '../install'; + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + + if (command === '--help' || command === 'help') { + printHelp(); + return; + } + + if (command === 'cache' && args[0] === 'clean') { + await cleanSolanaTestValidatorCache({ + ...readSolanaTestValidatorInstallOptionsFromPackageJson(), + ...parseSolanaTestValidatorInstallCliOptions(args.slice(1)), + }); + console.log('[solana-test-validator-up] cache cleaned'); + return; + } + + const installArgs = command === 'install' ? args : process.argv.slice(2); + const result = await installSolanaTestValidator({ + ...readSolanaTestValidatorInstallOptionsFromPackageJson(), + ...parseSolanaTestValidatorInstallCliOptions(installArgs), + }); + + console.log( + `[solana-test-validator-up] Solana release ${ + result.cacheHit ? 'found in cache' : 'installed' + }`, + ); + console.log( + `[solana-test-validator-up] solana-test-validator installed at ${result.binaryPath}`, + ); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); + +function printHelp(): void { + console.log(`Usage: solana-test-validator-up [install] [options] + solana-test-validator-up cache clean [options] + +Commands: + install Install solana-test-validator and solana CLI. Default command. + cache clean Remove cached solana-test-validator-up artifacts. + +Options: + --bin-directory Directory for executable wrappers. + Defaults to node_modules/.bin. + --cache-directory Cache directory. Defaults to .metamask/cache. + --release-url Solana release archive URL for the current platform. + --release-checksum Expected Solana release SHA-256 checksum. + --platform Override platform key, e.g. linux-x64. + --help Show this help text.`); +} diff --git a/packages/solana-test-validator-up/src/index.test.ts b/packages/solana-test-validator-up/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/solana-test-validator-up/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/solana-test-validator-up/src/index.ts b/packages/solana-test-validator-up/src/index.ts index 6972c11729..b1acf97cfc 100644 --- a/packages/solana-test-validator-up/src/index.ts +++ b/packages/solana-test-validator-up/src/index.ts @@ -1,9 +1,15 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { + SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE, + cleanSolanaTestValidatorCache, + getSolanaTestValidatorCacheDirectory, + installSolanaTestValidator, + parseSolanaTestValidatorInstallCliOptions, + readSolanaTestValidatorInstallOptionsFromPackageJson, +} from './install'; +export type { + SolanaTestValidatorArtifactConfig, + SolanaTestValidatorArtifactPlatformConfig, + SolanaTestValidatorInstallDependencies, + SolanaTestValidatorInstallOptions, + SolanaTestValidatorInstallResult, +} from './install'; diff --git a/packages/solana-test-validator-up/src/install.test.ts b/packages/solana-test-validator-up/src/install.test.ts new file mode 100644 index 0000000000..f5a1f6cf53 --- /dev/null +++ b/packages/solana-test-validator-up/src/install.test.ts @@ -0,0 +1,317 @@ +/* eslint-disable jest/expect-expect, n/no-sync */ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE, + cleanSolanaTestValidatorCache, + getSolanaTestValidatorCacheDirectory, + installSolanaTestValidator, + parseSolanaTestValidatorInstallCliOptions, + readSolanaTestValidatorInstallOptionsFromPackageJson, +} from './install'; +import type { SolanaTestValidatorInstallDependencies } from './install'; + +describe('solana-test-validator-up installer', () => { + let tempDirs: string[] = []; + + afterEach(() => { + for (const tempDir of tempDirs) { + rmSync(tempDir, { force: true, recursive: true }); + } + tempDirs = []; + }); + + it('pins an Agave release with Solana validator archives', () => { + assert.equal(SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE.version, 'v3.1.14'); + assert.equal( + SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE.platforms['darwin-arm64']?.checksum, + '54cfc2680bd6426fda04619ee01933f40a649c8056f3a61ba20dc54dd427ebed', + ); + assert.equal( + SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE.platforms['linux-x64']?.checksum, + '06f97c065cc977cbec2f13ffc9bc9d3b92fef485431fcb370a269de69532ef51', + ); + }); + + it('uses the local MetaMask cache when Yarn global cache is disabled', () => { + const cwd = createTempDir(); + writeFileSync(join(cwd, '.yarnrc.yml'), 'enableGlobalCache: false\n'); + + assert.equal( + getSolanaTestValidatorCacheDirectory({ cwd }), + join(cwd, '.metamask', 'cache'), + ); + }); + + it('reads pinned installer options from package.json', () => { + const cwd = createTempDir(); + writeFileSync( + join(cwd, 'package.json'), + JSON.stringify({ + solanaTestValidatorUp: { + release: { + platforms: { + 'linux-x64': { + checksum: + '06f97c065cc977cbec2f13ffc9bc9d3b92fef485431fcb370a269de69532ef51', + url: 'https://example.test/solana-release.tar.bz2', + }, + }, + version: 'test-version', + }, + }, + }), + ); + + assert.deepEqual( + readSolanaTestValidatorInstallOptionsFromPackageJson({ cwd }), + { + release: { + platforms: { + 'linux-x64': { + checksum: + '06f97c065cc977cbec2f13ffc9bc9d3b92fef485431fcb370a269de69532ef51', + url: 'https://example.test/solana-release.tar.bz2', + }, + }, + version: 'test-version', + }, + }, + ); + }); + + it('parses installer CLI options', () => { + assert.deepEqual( + parseSolanaTestValidatorInstallCliOptions([ + '--cache-directory', + '/tmp/cache', + '--bin-directory', + '/tmp/bin', + '--release-url', + 'https://example.test/solana-release.tar.bz2', + '--release-checksum', + 'abc123', + ]), + { + binDirectory: '/tmp/bin', + cacheDirectory: '/tmp/cache', + release: { + platforms: { + current: { + checksum: 'abc123', + url: 'https://example.test/solana-release.tar.bz2', + }, + }, + }, + }, + ); + }); + + it('downloads, verifies, caches, and installs Solana CLI wrappers', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const downloads: { destination: string; url: string }[] = []; + const releaseContent = 'fake solana release archive'; + const dependencies = createDependencies({ downloads, releaseContent }); + + const result = await installSolanaTestValidator( + { + binDirectory, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + release: { + platforms: { + 'darwin-arm64': { + checksum: sha256(releaseContent), + url: 'https://example.test/solana-release-aarch64.tar.bz2', + }, + }, + version: 'test-solana', + }, + }, + dependencies, + ); + + assert.equal(result.cacheHit, false); + assert.equal(result.version, 'test-solana'); + assert.equal( + result.binaryPath, + join(binDirectory, 'solana-test-validator'), + ); + assert.ok(result.solanaBinary.endsWith('/bin/solana')); + assert.ok(result.validatorBinary.endsWith('/bin/solana-test-validator')); + assert.ok(existsSync(result.binaryPath)); + assert.deepEqual( + downloads.map(({ url }) => url), + ['https://example.test/solana-release-aarch64.tar.bz2'], + ); + + const wrapperOutput = execFileSync( + process.execPath, + [result.binaryPath, '--version'], + { encoding: 'utf8' }, + ); + assert.equal(wrapperOutput.trim(), 'solana-test-validator --version'); + }); + + it('replaces stale bin symlinks without modifying their targets', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const releaseContent = 'fake solana release archive'; + const staleValidatorTarget = join(cwd, 'stale-validator-target'); + const staleSolanaTarget = join(cwd, 'stale-solana-target'); + + await mkdir(binDirectory, { recursive: true }); + writeFileSync(staleValidatorTarget, 'do not overwrite validator'); + writeFileSync(staleSolanaTarget, 'do not overwrite solana'); + symlinkSync( + staleValidatorTarget, + join(binDirectory, 'solana-test-validator'), + ); + symlinkSync(staleSolanaTarget, join(binDirectory, 'solana')); + + const result = await installSolanaTestValidator( + { + binDirectory, + cacheDirectory, + cwd, + platform: 'darwin-arm64', + release: { + platforms: { + 'darwin-arm64': { + checksum: sha256(releaseContent), + url: 'https://example.test/solana-release-aarch64.tar.bz2', + }, + }, + }, + }, + createDependencies({ releaseContent }), + ); + + assert.equal( + readFileSync(staleValidatorTarget, 'utf8'), + 'do not overwrite validator', + ); + assert.equal( + readFileSync(staleSolanaTarget, 'utf8'), + 'do not overwrite solana', + ); + assert.equal(lstatSync(result.binaryPath).isSymbolicLink(), false); + assert.equal( + lstatSync(join(binDirectory, 'solana')).isSymbolicLink(), + false, + ); + }); + + it('reuses cached release artifacts without downloading again', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + const binDirectory = join(cwd, 'node_modules', '.bin'); + const releaseContent = 'cached solana release archive'; + const release = { + platforms: { + 'linux-x64': { + checksum: sha256(releaseContent), + url: 'https://example.test/solana-release.tar.bz2', + }, + }, + version: 'cached-version', + }; + + await installSolanaTestValidator( + { binDirectory, cacheDirectory, cwd, platform: 'linux-x64', release }, + createDependencies({ releaseContent }), + ); + + const result = await installSolanaTestValidator( + { binDirectory, cacheDirectory, cwd, platform: 'linux-x64', release }, + { + downloadFile: async (): Promise => { + throw new Error('cache miss'); + }, + }, + ); + + assert.equal(result.cacheHit, true); + }); + + it('cleans only the solana-test-validator-up cache namespace', async () => { + const cwd = createTempDir(); + const cacheDirectory = join(cwd, '.metamask', 'cache'); + await mkdir(join(cacheDirectory, 'solana-test-validator-up', 'old'), { + recursive: true, + }); + await mkdir(join(cacheDirectory, 'foundryup', 'kept'), { + recursive: true, + }); + + await cleanSolanaTestValidatorCache({ cacheDirectory, cwd }); + + assert.equal( + existsSync(join(cacheDirectory, 'solana-test-validator-up')), + false, + ); + assert.equal(existsSync(join(cacheDirectory, 'foundryup', 'kept')), true); + }); + + function createTempDir(): string { + const tempDir = mkdtempSync( + join(tmpdir(), 'solana-test-validator-up-test-'), + ); + tempDirs.push(tempDir); + return tempDir; + } +}); + +function createDependencies({ + downloads = [], + releaseContent, +}: { + downloads?: { destination: string; url: string }[]; + releaseContent: string; +}): SolanaTestValidatorInstallDependencies { + return { + downloadFile: async (url, destination): Promise => { + downloads.push({ destination, url }); + await writeFile(destination, releaseContent); + }, + extractArchive: async (_archivePath, destination): Promise => { + const binDirectory = join(destination, 'solana-release', 'bin'); + await mkdir(binDirectory, { recursive: true }); + await writeExecutable( + join(binDirectory, 'solana-test-validator'), + 'solana-test-validator', + ); + await writeExecutable(join(binDirectory, 'solana'), 'solana'); + }, + }; +} + +async function writeExecutable(path: string, name: string): Promise { + await writeFile( + path, + `#!/usr/bin/env node\nconsole.log(${JSON.stringify(name)} + ' ' + process.argv.slice(2).join(' '));\n`, + { mode: 0o755 }, + ); +} + +function sha256(content: string): string { + return createHash('sha256').update(content).digest('hex'); +} diff --git a/packages/solana-test-validator-up/src/install.ts b/packages/solana-test-validator-up/src/install.ts new file mode 100644 index 0000000000..a0b0d7cf8c --- /dev/null +++ b/packages/solana-test-validator-up/src/install.ts @@ -0,0 +1,597 @@ +/* eslint-disable import-x/no-nodejs-modules, no-restricted-globals */ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + createWriteStream, + existsSync, + readdirSync, + readFileSync, + statSync, +} from 'node:fs'; +import { + chmod, + mkdir, + readFile, + rename, + rm, + unlink, + writeFile, +} from 'node:fs/promises'; +import { request as requestHttp } from 'node:http'; +import { request as requestHttps } from 'node:https'; +import { arch as osArch, homedir, platform as osPlatform } from 'node:os'; +import { dirname, join, relative } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +const SOLANA_TEST_VALIDATOR_CACHE_NAMESPACE = 'solana-test-validator-up'; +const RELEASE_CACHE_NAMESPACE = 'release'; + +export type SolanaTestValidatorArtifactConfig = { + platforms: Record< + string, + SolanaTestValidatorArtifactPlatformConfig | undefined + >; + version?: string; +}; + +export type SolanaTestValidatorArtifactPlatformConfig = { + checksum: string; + size?: number; + url: string; +}; + +export type SolanaTestValidatorInstallOptions = { + binDirectory?: string; + cacheDirectory?: string; + cwd?: string; + platform?: string; + release?: SolanaTestValidatorArtifactConfig; +}; + +export type SolanaTestValidatorInstallResult = { + binaryPath: string; + cacheHit: boolean; + checksum: string; + solanaBinary: string; + validatorBinary: string; + version?: string; +}; + +export type SolanaTestValidatorInstallDependencies = { + downloadFile?: (url: string, destination: string) => Promise; + extractArchive?: (archivePath: string, destination: string) => Promise; +}; + +type SolanaTestValidatorPackageJson = { + 'solana-test-validator-up'?: SolanaTestValidatorPackageJsonConfig; + solanaTestValidatorUp?: SolanaTestValidatorPackageJsonConfig; + solanatestvalidatorup?: SolanaTestValidatorPackageJsonConfig; +}; + +type SolanaTestValidatorPackageJsonConfig = Pick< + SolanaTestValidatorInstallOptions, + 'binDirectory' | 'cacheDirectory' | 'release' +>; + +export const SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE: SolanaTestValidatorArtifactConfig = + { + version: 'v3.1.14', + platforms: { + 'darwin-arm64': { + checksum: + '54cfc2680bd6426fda04619ee01933f40a649c8056f3a61ba20dc54dd427ebed', + size: 77_158_067, + url: 'https://github.com/anza-xyz/agave/releases/download/v3.1.14/solana-release-aarch64-apple-darwin.tar.bz2', + }, + 'darwin-x64': { + checksum: + 'e3768ed01daa1e3cfc02af3e3eb396cec2d48a99ecf80cd5d7bdff510f808d1f', + size: 81_239_759, + url: 'https://github.com/anza-xyz/agave/releases/download/v3.1.14/solana-release-x86_64-apple-darwin.tar.bz2', + }, + 'linux-x64': { + checksum: + '06f97c065cc977cbec2f13ffc9bc9d3b92fef485431fcb370a269de69532ef51', + size: 215_235_690, + url: 'https://github.com/anza-xyz/agave/releases/download/v3.1.14/solana-release-x86_64-unknown-linux-gnu.tar.bz2', + }, + }, + }; + +export function getSolanaTestValidatorCacheDirectory({ + cwd = process.cwd(), + homeDirectory = homedir(), +}: { + cwd?: string; + homeDirectory?: string; +} = {}): string { + const yarnRcPath = join(cwd, '.yarnrc.yml'); + + try { + const yarnRc = readFileSync(yarnRcPath, 'utf8'); + if (/^\s*enableGlobalCache:\s*true\s*$/mu.test(yarnRc)) { + return join(homeDirectory, '.cache', 'metamask'); + } + } catch (error) { + if (!isFileMissingError(error)) { + console.warn( + `Warning: Error reading ${yarnRcPath}, using local solana-test-validator-up cache:`, + error, + ); + } + } + + return join(cwd, '.metamask', 'cache'); +} + +export function readSolanaTestValidatorInstallOptionsFromPackageJson({ + cwd = process.cwd(), + packageJsonPath = join(cwd, 'package.json'), +}: { + cwd?: string; + packageJsonPath?: string; +} = {}): SolanaTestValidatorInstallOptions { + const packageJson = JSON.parse( + readFileSync(packageJsonPath, 'utf8'), + ) as SolanaTestValidatorPackageJson; + const config = + packageJson.solanaTestValidatorUp ?? + packageJson.solanatestvalidatorup ?? + packageJson['solana-test-validator-up']; + const options: SolanaTestValidatorInstallOptions = {}; + + if (config?.binDirectory) { + options.binDirectory = config.binDirectory; + } + if (config?.cacheDirectory) { + options.cacheDirectory = config.cacheDirectory; + } + if (config?.release) { + options.release = config.release; + } + + return options; +} + +export function parseSolanaTestValidatorInstallCliOptions( + args: string[], +): SolanaTestValidatorInstallOptions { + const options: SolanaTestValidatorInstallOptions = {}; + const release: Partial = {}; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + const value = args[index + 1]; + + switch (arg) { + case '--bin-directory': + options.binDirectory = readCliValue(arg, value); + index += 1; + break; + case '--cache-directory': + options.cacheDirectory = readCliValue(arg, value); + index += 1; + break; + case '--platform': + options.platform = readCliValue(arg, value); + index += 1; + break; + case '--release-checksum': + release.checksum = readCliValue(arg, value); + index += 1; + break; + case '--release-url': + release.url = readCliValue(arg, value); + index += 1; + break; + default: + throw new Error( + `Unknown solana-test-validator-up install option: ${arg}`, + ); + } + } + + if (release.url || release.checksum) { + options.release = { + platforms: { + current: requireCompletePlatformConfig( + release, + 'Solana release CLI options', + ), + }, + }; + } + + return options; +} + +export async function installSolanaTestValidator( + options: SolanaTestValidatorInstallOptions = {}, + dependencies: SolanaTestValidatorInstallDependencies = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getSolanaTestValidatorCacheDirectory({ cwd }); + const binDirectory = + options.binDirectory ?? join(cwd, 'node_modules', '.bin'); + const platformKey = options.platform ?? getPlatformKey(); + const release = options.release ?? SOLANA_TEST_VALIDATOR_DEFAULT_RELEASE; + const releaseConfig = resolvePlatformConfig( + release, + platformKey, + 'Solana release', + ); + const releaseResult = await installSolanaRelease( + { cacheDirectory, config: releaseConfig }, + dependencies, + ); + const binaryPath = await installExecutableWrapper({ + binDirectory, + commandName: 'solana-test-validator', + executablePath: releaseResult.validatorBinary, + }); + await installExecutableWrapper({ + binDirectory, + commandName: 'solana', + executablePath: releaseResult.solanaBinary, + }); + + return { + binaryPath, + cacheHit: releaseResult.cacheHit, + checksum: releaseConfig.checksum, + solanaBinary: releaseResult.solanaBinary, + validatorBinary: releaseResult.validatorBinary, + version: release.version, + }; +} + +export async function cleanSolanaTestValidatorCache( + options: Pick< + SolanaTestValidatorInstallOptions, + 'cacheDirectory' | 'cwd' + > = {}, +): Promise { + const cwd = options.cwd ?? process.cwd(); + const cacheDirectory = + options.cacheDirectory ?? getSolanaTestValidatorCacheDirectory({ cwd }); + + await rm(join(cacheDirectory, SOLANA_TEST_VALIDATOR_CACHE_NAMESPACE), { + force: true, + recursive: true, + }); +} + +async function installSolanaRelease( + { + cacheDirectory, + config, + }: { + cacheDirectory: string; + config: SolanaTestValidatorArtifactPlatformConfig; + }, + dependencies: SolanaTestValidatorInstallDependencies, +): Promise<{ + cacheHit: boolean; + solanaBinary: string; + validatorBinary: string; +}> { + const cacheKey = getCacheKey(config); + const cacheRoot = join( + cacheDirectory, + SOLANA_TEST_VALIDATOR_CACHE_NAMESPACE, + RELEASE_CACHE_NAMESPACE, + cacheKey, + ); + const checksumPath = join(cacheRoot, '.source-checksum'); + const cached = findSolanaBinaries(cacheRoot); + + if ( + cached && + existsSync(checksumPath) && + readFileSync(checksumPath, 'utf8') === config.checksum + ) { + return { cacheHit: true, ...cached }; + } + + const tempRoot = `${cacheRoot}.downloading`; + const archivePath = join(tempRoot, 'solana-release.tar.bz2'); + const downloadFile = dependencies.downloadFile ?? downloadFileFromUrl; + const extractArchive = dependencies.extractArchive ?? extractTarBz2Archive; + + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + await mkdir(tempRoot, { recursive: true }); + + try { + await downloadFile(config.url, archivePath); + await verifyFileChecksum( + archivePath, + config.checksum, + 'Downloaded Solana release', + ); + await extractArchive(archivePath, tempRoot); + + const binaries = findSolanaBinaries(tempRoot); + if (!binaries) { + throw new Error( + 'Solana release archive did not contain bin/solana-test-validator and bin/solana.', + ); + } + + await writeFile(checksumPath.replace(cacheRoot, tempRoot), config.checksum); + await mkdir(dirname(cacheRoot), { recursive: true }); + await rename(tempRoot, cacheRoot); + + return { + cacheHit: false, + solanaBinary: binaries.solanaBinary.replace(tempRoot, cacheRoot), + validatorBinary: binaries.validatorBinary.replace(tempRoot, cacheRoot), + }; + } catch (error) { + await rm(tempRoot, { force: true, recursive: true }); + await rm(cacheRoot, { force: true, recursive: true }); + throw error; + } +} + +async function installExecutableWrapper({ + binDirectory, + commandName, + executablePath, +}: { + binDirectory: string; + commandName: string; + executablePath: string; +}): Promise { + const binaryPath = join(binDirectory, commandName); + const relativeExecutablePath = relative(binDirectory, executablePath); + + await mkdir(binDirectory, { recursive: true }); + await unlink(binaryPath).catch((error) => { + if (!isFileMissingError(error)) { + throw error; + } + }); + await writeFile( + binaryPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process'); +const path = require('node:path'); + +const executablePath = path.resolve(__dirname, ${JSON.stringify(relativeExecutablePath)}); +const result = spawnSync(executablePath, process.argv.slice(2), { + stdio: 'inherit', +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +if (result.signal) { + process.kill(process.pid, result.signal); +} + +process.exit(result.status ?? 0); +`, + ); + await chmod(binaryPath, 0o755); + + return binaryPath; +} + +function findSolanaBinaries( + root: string, +): { solanaBinary: string; validatorBinary: string } | undefined { + const validatorBinary = findExecutable(root, 'solana-test-validator'); + const solanaBinary = findExecutable(root, 'solana'); + + if (!validatorBinary || !solanaBinary) { + return undefined; + } + + return { solanaBinary, validatorBinary }; +} + +function findExecutable(root: string, name: string): string | undefined { + if (!existsSync(root)) { + return undefined; + } + + for (const entry of readdirSync(root)) { + const entryPath = join(root, entry); + const stat = statSync(entryPath); + if (stat.isDirectory()) { + const found = findExecutable(entryPath, name); + if (found) { + return found; + } + } else if (entry === name) { + return entryPath; + } + } + + return undefined; +} + +function resolvePlatformConfig( + config: SolanaTestValidatorArtifactConfig, + platform: string, + label: string, +): SolanaTestValidatorArtifactPlatformConfig { + const platformConfig = config.platforms[platform] ?? config.platforms.current; + + if (!platformConfig) { + throw new Error(`No ${label} is configured for ${platform}.`); + } + + return platformConfig; +} + +function requireCompletePlatformConfig( + config: Partial, + label: string, +): SolanaTestValidatorArtifactPlatformConfig { + if (!config.url || !config.checksum) { + throw new Error(`${label} require both a URL and a checksum.`); + } + + return { + checksum: config.checksum, + url: config.url, + }; +} + +function getCacheKey( + config: SolanaTestValidatorArtifactPlatformConfig, +): string { + return createHash('sha256') + .update(`${config.url}:${config.checksum}`) + .digest('hex'); +} + +async function verifyFileChecksum( + filePath: string, + expectedChecksum: string, + label: string, +): Promise { + const checksum = createHash('sha256') + .update(await readFile(filePath)) + .digest('hex'); + + if (checksum !== expectedChecksum) { + throw new Error( + `${label} checksum mismatch. Expected ${expectedChecksum}, got ${checksum}.`, + ); + } +} + +async function downloadFileFromUrl( + url: string, + destination: string, +): Promise { + await mkdir(dirname(destination), { recursive: true }); + await pipeline( + await openDownloadStream(new URL(url)), + createWriteStream(destination), + ); +} + +async function openDownloadStream( + url: URL, + redirectsRemaining = 5, +): Promise { + const request = url.protocol === 'http:' ? requestHttp : requestHttps; + + return await new Promise((resolvePromise, rejectPromise) => { + const req = request(url, (response) => { + const { headers, statusCode, statusMessage } = response; + + if ( + statusCode && + statusCode >= 300 && + statusCode < 400 && + headers.location + ) { + response.resume(); + if (redirectsRemaining <= 0) { + rejectPromise(new Error(`Too many redirects downloading ${url}`)); + return; + } + + openDownloadStream( + new URL(headers.location, url), + redirectsRemaining - 1, + ) + .then(resolvePromise) + .catch(rejectPromise); + return; + } + + if (!statusCode || statusCode < 200 || statusCode >= 300) { + response.resume(); + rejectPromise( + new Error( + `Request to ${url} failed with ${statusCode ?? 'unknown'} ${ + statusMessage ?? '' + }`.trim(), + ), + ); + return; + } + + resolvePromise(response); + }); + + req.on('error', rejectPromise); + req.end(); + }); +} + +async function extractTarBz2Archive( + archivePath: string, + destination: string, +): Promise { + await runCommand('tar', ['-xjf', archivePath, '-C', destination]); +} + +async function runCommand(command: string, args: string[]): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + shell: false, + stdio: ['ignore', 'ignore', 'pipe'], + }); + let stderr = ''; + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', rejectPromise); + child.on('close', (code) => { + if (code === 0) { + resolvePromise(); + return; + } + rejectPromise( + new Error( + `${command} ${args.join(' ')} failed with code ${code}: ${stderr}`, + ), + ); + }); + }); +} + +function getPlatformKey(): string { + const platform = osPlatform(); + const arch = osArch(); + + if (platform === 'darwin' && arch === 'arm64') { + return 'darwin-arm64'; + } + if (platform === 'darwin' && arch === 'x64') { + return 'darwin-x64'; + } + if (platform === 'linux' && arch === 'x64') { + return 'linux-x64'; + } + + return `${platform}-${arch}`; +} + +function readCliValue(arg: string, value: string | undefined): string { + if (!value || value.startsWith('--')) { + throw new Error(`${arg} requires a value.`); + } + + return value; +} + +function isFileMissingError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + Object.prototype.hasOwnProperty.call(error, 'code') && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} diff --git a/yarn.lock b/yarn.lock index a1478fa549..985cf33f58 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8558,6 +8558,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" + bin: + solana-test-validator-up: ./dist/bin/solana-test-validator-up.mjs languageName: unknown linkType: soft From ae4521560b8c8daba6107a663952efe4acbd096b Mon Sep 17 00:00:00 2001 From: Ulisses Ferreira Date: Fri, 19 Jun 2026 14:32:11 +0100 Subject: [PATCH 2/2] chore: keep solana-test-validator-up unreleased at 0.0.0 and link changelog to this PR --- packages/solana-test-validator-up/CHANGELOG.md | 2 +- packages/solana-test-validator-up/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/solana-test-validator-up/CHANGELOG.md b/packages/solana-test-validator-up/CHANGELOG.md index 654712d58b..ddce53bb79 100644 --- a/packages/solana-test-validator-up/CHANGELOG.md +++ b/packages/solana-test-validator-up/CHANGELOG.md @@ -9,6 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add the `@metamask/solana-test-validator-up` package ([#8826](https://github.com/MetaMask/core/pull/8826)). +- Add the `@metamask/solana-test-validator-up` package ([#9210](https://github.com/MetaMask/core/pull/9210)). [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/solana-test-validator-up/package.json b/packages/solana-test-validator-up/package.json index 8b5e912fcb..d09693597e 100644 --- a/packages/solana-test-validator-up/package.json +++ b/packages/solana-test-validator-up/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/solana-test-validator-up", - "version": "0.1.0", + "version": "0.0.0", "description": "solana-test-validator runtime installer for MetaMask E2E tests", "keywords": [ "Ethereum",