#![allow(dead_code)]
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::thread;
use std::time::{Duration, Instant};
static COUNTER: AtomicU64 = AtomicU64::new(0);
use anyhow::{bail, Context, Result};
use metaboss_lib::decode::decode_metadata_from_mint;
use regex::Regex;
use serde_json::json;
use solana_client::rpc_client::RpcClient;
use solana_sdk::commitment_config::CommitmentConfig;
use solana_sdk::native_token::LAMPORTS_PER_SOL;
use solana_sdk::signature::Keypair;
use solana_sdk::signer::Signer;
const BPF_PROGRAMS: &[(&str, &str)] = &[
(
"metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s",
"tests/fixtures/programs/token_metadata.so",
),
(
"CoREENxT6tW1HoK8ypY1SxRMZTcVPm7R94rH4PZNhX7d",
"tests/fixtures/programs/mpl_core.so",
),
(
"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA",
"tests/fixtures/programs/spl_token.so",
),
(
"TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb",
"tests/fixtures/programs/spl_token_2022.so",
),
(
"ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL",
"tests/fixtures/programs/associated_token_account.so",
),
];
const VALIDATOR_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
const AIRDROP_SOL: u64 = 10;
pub struct CommandOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
}
pub struct TestContext {
pub rpc_url: String,
pub client: RpcClient,
pub keypair_path: String,
pub keypair: Keypair,
validator_process: Child,
temp_dir: PathBuf,
extra_temp_dirs: Vec<PathBuf>,
}
impl TestContext {
pub fn new() -> Result<Self> {
let temp_dir = std::env::temp_dir().join(format!(
"metaboss-test-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&temp_dir).context("Failed to create temp directory")?;
let ledger_dir = temp_dir.join("ledger");
let keypair_path = temp_dir.join("test-keypair.json");
let keypair = Keypair::new();
let keypair_bytes: Vec<u8> = keypair.to_bytes().to_vec();
let keypair_json = serde_json::to_string(&keypair_bytes)?;
fs::write(&keypair_path, &keypair_json)?;
let rpc_url = "http://localhost:8899".to_string();
let mut cmd = Command::new("solana-test-validator");
cmd.arg("--ledger")
.arg(&ledger_dir)
.arg("--reset")
.arg("--quiet");
for (address, so_path) in BPF_PROGRAMS {
cmd.arg("--bpf-program").arg(address).arg(so_path);
}
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let validator_process = cmd
.spawn()
.context("Failed to start solana-test-validator")?;
let client = RpcClient::new_with_commitment(rpc_url.clone(), CommitmentConfig::confirmed());
Self::wait_for_validator(&client)?;
Self::airdrop_and_confirm(&client, &keypair)?;
Ok(Self {
rpc_url,
client,
keypair_path: keypair_path.to_string_lossy().to_string(),
keypair,
validator_process,
temp_dir,
extra_temp_dirs: Vec::new(),
})
}
fn wait_for_validator(client: &RpcClient) -> Result<()> {
let start = Instant::now();
loop {
if start.elapsed() > VALIDATOR_STARTUP_TIMEOUT {
bail!("Timed out waiting for solana-test-validator to start");
}
if client.get_latest_blockhash().is_ok() {
return Ok(());
}
thread::sleep(Duration::from_millis(200));
}
}
fn airdrop_and_confirm(client: &RpcClient, keypair: &Keypair) -> Result<()> {
let sig = client
.request_airdrop(&keypair.pubkey(), AIRDROP_SOL * LAMPORTS_PER_SOL)
.context("Airdrop request failed")?;
let start = Instant::now();
loop {
if start.elapsed() > Duration::from_secs(15) {
bail!("Timed out waiting for airdrop confirmation");
}
if client.confirm_transaction(&sig).unwrap_or(false) {
return Ok(());
}
thread::sleep(Duration::from_millis(200));
}
}
pub fn run_metaboss(&self, args: &[&str]) -> CommandOutput {
let metaboss_bin = Self::metaboss_bin_path();
let mut full_args = vec!["--rpc", &self.rpc_url];
full_args.extend_from_slice(args);
let output: Output = Command::new(&metaboss_bin)
.args(&full_args)
.output()
.unwrap_or_else(|e| panic!("Failed to run metaboss at {:?}: {}", metaboss_bin, e));
CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
success: output.status.success(),
}
}
fn metaboss_bin_path() -> PathBuf {
let mut path = std::env::current_exe().expect("cannot determine test binary path");
path.pop(); path.pop(); path.push("metaboss");
if path.exists() {
return path;
}
PathBuf::from("./target/debug/metaboss")
}
pub fn create_test_nft_json(&self, path: &Path) -> Result<()> {
let creator_address = self.keypair.pubkey().to_string();
let metadata = json!({
"name": "Test NFT",
"symbol": "TNFT",
"uri": "https://arweave.net/FPGAv1XnyZidnqquOdEbSY6_ES735ckcDTdaAtI7GFw",
"seller_fee_basis_points": 100,
"creators": [
{
"address": creator_address,
"verified": false,
"share": 100
}
]
});
let mut file = fs::File::create(path)?;
file.write_all(serde_json::to_string_pretty(&metadata)?.as_bytes())?;
Ok(())
}
pub fn create_temp_dir(&mut self, label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"metaboss-test-{}-{}-{}",
label,
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&dir).expect("failed to create temp dir");
self.extra_temp_dirs.push(dir.clone());
dir
}
}
impl Drop for TestContext {
fn drop(&mut self) {
let _ = self.validator_process.kill();
let _ = self.validator_process.wait();
let _ = fs::remove_dir_all(&self.temp_dir);
for dir in &self.extra_temp_dirs {
let _ = fs::remove_dir_all(dir);
}
}
}
pub fn assert_success(output: &CommandOutput) {
assert!(
output.success,
"Command failed.\nstdout:\n{}\nstderr:\n{}",
output.stdout, output.stderr,
);
}
pub fn parse_mint_from_output(output: &str) -> String {
let re = Regex::new(r"Mint account: (\S+)").expect("invalid regex");
re.captures(output)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.expect("Could not find 'Mint account: <pubkey>' in output")
}
pub fn strip_debug_quotes(s: &str) -> String {
s.trim_matches('"').to_string()
}
pub fn mint_test_nft(ctx: &TestContext, temp_dir: &Path) -> Result<String> {
let nft_json = temp_dir.join("test_nft.json");
ctx.create_test_nft_json(&nft_json)?;
let nft_json_str = nft_json.to_string_lossy().to_string();
let output = ctx.run_metaboss(&["mint", "one", "-d", &nft_json_str, "-k", &ctx.keypair_path]);
assert_success(&output);
let raw_mint = parse_mint_from_output(&output.stdout);
Ok(strip_debug_quotes(&raw_mint))
}
pub fn decode_onchain_metadata(
ctx: &TestContext,
mint_str: &str,
) -> Result<mpl_token_metadata::accounts::Metadata> {
let metadata = decode_metadata_from_mint(&ctx.client, mint_str.to_string())
.map_err(|e| anyhow::anyhow!("Failed to decode metadata: {:?}", e))?;
Ok(metadata)
}
pub fn trim_null(s: &str) -> &str {
s.trim_matches(char::from(0))
}