mirror of
https://github.com/logos-co/scaffold.git
synced 2026-08-27 12:41:13 +00:00
The deploy program_id fix switched the vendored-spel call from `inspect` to `program-id`; update the test stub so it responds to `program-id` (still accepting `inspect` for the `spel` passthrough proxy tests, which forward it as an arbitrary arg). Restores all 168 CLI tests to green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EsVXmNmwMCWi5yGgGLVe6P
4541 lines
153 KiB
Rust
4541 lines
153 KiB
Rust
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::net::{TcpListener, TcpStream};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{
|
|
atomic::{AtomicBool, Ordering},
|
|
Arc,
|
|
};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
use assert_cmd::Command;
|
|
use flate2::read::GzDecoder;
|
|
use predicates::prelude::*;
|
|
use tar::Archive;
|
|
use tempfile::tempdir;
|
|
|
|
const TEST_PIN: &str = "767b5afd388c7981bcdf6f5b5c80159607e07e5b";
|
|
const VALID_ACCOUNT_ID: &str = "6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV";
|
|
const VALID_PUBLIC_ADDRESS: &str = "Public/6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV";
|
|
const DEFAULT_WALLET_PASSWORD: &str = "logos-scaffold-v0";
|
|
const GUEST_BIN_REL_PATH: &str =
|
|
"target/riscv-guest/example_program_deployment_methods/example_program_deployment_programs/riscv32im-risc0-zkvm-elf/release";
|
|
|
|
/// Minimal valid `scaffold.toml` content for tests that only need the project
|
|
/// context to exist (no basecamp section). Older tests in this file inline
|
|
/// the same content; new tests should prefer this helper.
|
|
const MINIMAL_SCAFFOLD_TOML: &str = r#"[scaffold]
|
|
version = "0.2.0"
|
|
cache_root = "cache"
|
|
|
|
[repos.lez]
|
|
source = "https://example/lez.git"
|
|
path = "lez"
|
|
pin = "deadbeef"
|
|
|
|
[repos.spel]
|
|
source = "https://example/spel.git"
|
|
path = "spel"
|
|
pin = "deadbeef"
|
|
|
|
[wallet]
|
|
home_dir = ".scaffold/wallet"
|
|
|
|
[framework]
|
|
kind = "default"
|
|
version = "0.1.0"
|
|
|
|
[framework.idl]
|
|
spec = "lssa-idl/0.1.0"
|
|
path = "idl"
|
|
|
|
[localnet]
|
|
port = 3040
|
|
risc0_dev_mode = true
|
|
"#;
|
|
|
|
fn fake_basecamp_state() -> String {
|
|
let bin = assert_cmd::cargo::cargo_bin!("logos-scaffold");
|
|
format!(
|
|
"pin=deadbeef\nbasecamp_bin={}\nlgpm_bin={}\n",
|
|
bin.display(),
|
|
bin.display()
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn root_help_lists_quiet_flag_and_examples() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("--quiet")
|
|
.and(predicate::str::contains("Examples:"))
|
|
.and(predicate::str::contains("LOGOS_SCAFFOLD_QUIET")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_help_documents_json_for_automation() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("deploy")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("--json")
|
|
.and(predicate::str::contains("Machine-readable output"))
|
|
.and(predicate::str::contains("LOGOS_SCAFFOLD_WALLET_PASSWORD")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_reset_help_lists_dry_run() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("localnet")
|
|
.arg("reset")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("--dry-run"));
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_reset_dry_run_prints_plan_without_mutations() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("reset")
|
|
.arg("--dry-run")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("dry-run: localnet reset")
|
|
.and(predicate::str::contains("planned: delete sequencer DB")),
|
|
);
|
|
|
|
let rocksdb = lez_path.join("rocksdb");
|
|
assert!(
|
|
!rocksdb.exists(),
|
|
"dry-run must not create or require rocksdb at {}",
|
|
rocksdb.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_reset_without_yes_or_dry_run_refuses_destructive_default() {
|
|
// C1: `localnet reset` with no flags must refuse rather than silently
|
|
// wiping the sequencer DB. The error message advertises the two valid
|
|
// shapes (`--yes` to confirm, `--dry-run` to preview) so an agent that
|
|
// got the syntax wrong can copy-paste the corrected form.
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
// Seed the sequencer DB so we can verify the refusal does NOT delete it.
|
|
let rocksdb = lez_path.join("rocksdb");
|
|
fs::create_dir_all(&rocksdb).expect("seed rocksdb");
|
|
fs::write(rocksdb.join("CURRENT"), b"sentinel").expect("seed db file");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("reset")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("destructive")
|
|
.and(predicate::str::contains("--yes"))
|
|
.and(predicate::str::contains("--dry-run")),
|
|
);
|
|
|
|
assert!(
|
|
rocksdb.join("CURRENT").exists(),
|
|
"refusal must leave rocksdb untouched at {}",
|
|
rocksdb.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_help_documents_inner_cli_discovery() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("wallet")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("wallet -- --help"));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_passthrough_works_with_leading_quiet_flag() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("--quiet")
|
|
.arg("wallet")
|
|
.arg("--")
|
|
.arg("account")
|
|
.arg("list")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Preconfigured Public/"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("-q")
|
|
.arg("wallet")
|
|
.arg("--")
|
|
.arg("account")
|
|
.arg("list")
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_passthrough_accepts_quiet_between_subcommand_and_separator() {
|
|
// Copilot review on PR #86: `--quiet` is `global = true`, so an agent
|
|
// would reasonably try `lgs wallet -q -- account list` (or `--quiet`).
|
|
// The passthrough sniffer must accept the in-between form and still
|
|
// suppress the `$ <wallet-bin>` echo line on stdout.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("-q")
|
|
.arg("--")
|
|
.arg("account")
|
|
.arg("list")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Preconfigured Public/"));
|
|
let stdout = String::from_utf8_lossy(&assert.get_output().stdout).into_owned();
|
|
assert!(
|
|
!stdout.contains("$ "),
|
|
"in-between -q must suppress the `$ <cmd>` echo line, got:\n{stdout}"
|
|
);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("--quiet")
|
|
.arg("--")
|
|
.arg("account")
|
|
.arg("list")
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn logos_scaffold_quiet_env_suppresses_command_echo_case_insensitively() {
|
|
// Pins the contract advertised by `cli_help::EXAMPLES_ROOT` and the
|
|
// `--quiet` help string: `LOGOS_SCAFFOLD_QUIET=` accepts `1`, `true`,
|
|
// `yes`, `on` case-insensitively. Without the env var the wallet
|
|
// passthrough echoes `$ <wallet-bin> account list` via `run_forwarded`;
|
|
// any accepted truthy value must suppress that line.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let baseline = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["wallet", "--", "account", "list"])
|
|
.assert()
|
|
.success();
|
|
let baseline_stdout = String::from_utf8_lossy(&baseline.get_output().stdout).into_owned();
|
|
assert!(
|
|
baseline_stdout.contains("$ "),
|
|
"baseline (no quiet) must echo `$ <cmd>`, got:\n{baseline_stdout}"
|
|
);
|
|
|
|
// Each row must suppress the `$ ` echo. Mixed-case values pin the
|
|
// case-insensitive branch — they used to slip through the `matches!`
|
|
// arm and still echo.
|
|
for value in ["1", "true", "TRUE", "True", "yes", "YES", "Yes", "on", "ON"] {
|
|
let out = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("LOGOS_SCAFFOLD_QUIET", value)
|
|
.args(["wallet", "--", "account", "list"])
|
|
.assert()
|
|
.success();
|
|
let stdout = String::from_utf8_lossy(&out.get_output().stdout).into_owned();
|
|
assert!(
|
|
!stdout.contains("$ "),
|
|
"LOGOS_SCAFFOLD_QUIET={value} should suppress `$ ` echo, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
// Negative cases: empty / `0` / `false` / garbage must NOT suppress.
|
|
for value in ["", "0", "false", "no", "off", "garbage"] {
|
|
let out = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("LOGOS_SCAFFOLD_QUIET", value)
|
|
.args(["wallet", "--", "account", "list"])
|
|
.assert()
|
|
.success();
|
|
let stdout = String::from_utf8_lossy(&out.get_output().stdout).into_owned();
|
|
assert!(
|
|
stdout.contains("$ "),
|
|
"LOGOS_SCAFFOLD_QUIET={value:?} must NOT suppress echo, got:\n{stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn create_help_does_not_mutate_filesystem() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("create")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage:"));
|
|
|
|
assert!(
|
|
!temp.path().join("--help").exists(),
|
|
"--help must not be treated as project name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_help_lists_list_topup_and_default_commands() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("wallet")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("list")
|
|
.and(predicate::str::contains("topup"))
|
|
.and(predicate::str::contains("default")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_help_lists_optional_program_name() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("deploy")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("[PROGRAM_NAME]"));
|
|
// Note: output includes [OPTIONS] when extra flags are present
|
|
}
|
|
|
|
#[test]
|
|
fn report_help_lists_out_and_tail_flags() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("report")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("--out")
|
|
.and(predicate::str::contains("--tail"))
|
|
.and(predicate::str::contains(
|
|
"Collect a sanitized diagnostics archive",
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_generates_default_archive_with_warning_and_manifest() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
"sequencer started\n",
|
|
)
|
|
.expect("write sequencer log");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("report complete")
|
|
.and(predicate::str::contains("archive:"))
|
|
.and(predicate::str::contains(
|
|
"Inspect every file before sharing",
|
|
)),
|
|
);
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
assert!(archive_path.exists(), "expected default report archive");
|
|
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
assert!(archive_entry_exists(&entries, "README.txt"));
|
|
assert!(archive_entry_exists(&entries, "manifest.json"));
|
|
assert!(archive_entry_exists(&entries, "diagnostics/doctor.json"));
|
|
assert!(archive_entry_exists(
|
|
&entries,
|
|
"diagnostics/localnet-status.json"
|
|
));
|
|
assert!(archive_entry_exists(
|
|
&entries,
|
|
"summaries/build-evidence.json"
|
|
));
|
|
|
|
let readme = archive_entry_content(&entries, "README.txt");
|
|
assert!(
|
|
readme.contains("best-effort basis"),
|
|
"README should include warning, got: {readme}"
|
|
);
|
|
|
|
let build_evidence = archive_entry_content(&entries, "summaries/build-evidence.json");
|
|
assert!(
|
|
build_evidence.contains("No build commands were executed"),
|
|
"build evidence should confirm metadata-only mode, got: {build_evidence}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_supports_custom_output_path() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let custom_out = temp.path().join("artifacts/support-report.tar.gz");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.arg("--out")
|
|
.arg(&custom_out)
|
|
.assert()
|
|
.success();
|
|
|
|
assert!(
|
|
custom_out.exists(),
|
|
"custom report output should exist at {}",
|
|
custom_out.display()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_excludes_wallet_files_from_archive() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let wallet_dir = temp.path().join(".scaffold/wallet");
|
|
fs::create_dir_all(&wallet_dir).expect("create wallet dir");
|
|
fs::write(wallet_dir.join("config.json"), "{ \"test\": true }\n").expect("write config");
|
|
fs::write(
|
|
wallet_dir.join("storage.json"),
|
|
"{ \"secret_spending_key\": [1,2,3] }\n",
|
|
)
|
|
.expect("write storage");
|
|
fs::write(
|
|
wallet_dir.join("wallet_config.json"),
|
|
"{ \"initial_accounts\": [] }\n",
|
|
)
|
|
.expect("write wallet config");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
for (path, _) in &entries {
|
|
assert!(
|
|
!path.contains(".scaffold/wallet/"),
|
|
"wallet files must be excluded, found archive path: {path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn report_redacts_sensitive_values_in_logs() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
"password=super-secret\napi_token=abc123\nrpc=http://user:pass@127.0.0.1:3040\n",
|
|
)
|
|
.expect("write sequencer log");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let log_body = archive_entry_content(&entries, "logs/sequencer.log");
|
|
|
|
assert!(!log_body.contains("super-secret"));
|
|
assert!(!log_body.contains("abc123"));
|
|
assert!(!log_body.contains("user:pass@"));
|
|
assert!(
|
|
log_body.contains("[REDACTED]"),
|
|
"expected redaction marker in sanitized log, got: {log_body}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_keeps_non_utf8_logs_via_lossy_decoding() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
[b'o', b'k', b'\n', 0xff, 0xfe, b'\n'],
|
|
)
|
|
.expect("write non-utf8 log");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let log_body = archive_entry_content(&entries, "logs/sequencer.log");
|
|
|
|
assert!(log_body.contains("ok"), "expected preserved utf8 content");
|
|
assert!(
|
|
log_body.contains('\u{fffd}'),
|
|
"expected lossy replacement chars for invalid utf8, got: {log_body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_manifest_scrubs_absolute_paths_in_warnings() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
// No wallet stub — wallet binary is missing at lez/target/release/wallet
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
write_wallet_config(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let manifest = archive_entry_content(&entries, "manifest.json");
|
|
let temp_root = temp.path().to_string_lossy();
|
|
|
|
assert!(
|
|
!manifest.contains(temp_root.as_ref()),
|
|
"manifest should not leak absolute project path, got: {manifest}"
|
|
);
|
|
assert!(
|
|
manifest.contains("tool probe `wallet` did not succeed"),
|
|
"manifest should contain wallet probe warning, got: {manifest}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_sanitizes_localnet_status_log_path() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let localnet_status = archive_entry_content(&entries, "diagnostics/localnet-status.json");
|
|
let temp_root = temp.path().to_string_lossy();
|
|
|
|
assert!(
|
|
!localnet_status.contains(temp_root.as_ref()),
|
|
"localnet status should not leak project abs path, got: {localnet_status}"
|
|
);
|
|
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(localnet_status).expect("valid localnet status json");
|
|
let log_path = value
|
|
.get("log_path")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("log_path string");
|
|
assert!(
|
|
log_path.contains("<PROJECT_ROOT>"),
|
|
"expected scrubbed project placeholder in localnet log path, got: {log_path}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_sanitizes_doctor_json_paths() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let doctor = archive_entry_content(&entries, "diagnostics/doctor.json");
|
|
let temp_root = temp.path().to_string_lossy();
|
|
|
|
assert!(
|
|
!doctor.contains(temp_root.as_ref()),
|
|
"doctor report should not leak absolute paths, got: {doctor}"
|
|
);
|
|
assert!(
|
|
doctor.contains("<PROJECT_ROOT>"),
|
|
"doctor report should include scrubbed placeholder for project path, got: {doctor}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_scrubs_tool_command_paths_in_summary() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let tool_versions = archive_entry_content(&entries, "summaries/tool-versions.json");
|
|
let temp_root = temp.path().to_string_lossy();
|
|
|
|
assert!(
|
|
!tool_versions.contains(temp_root.as_ref()),
|
|
"tool summary should not leak absolute paths, got: {tool_versions}"
|
|
);
|
|
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(tool_versions).expect("valid tool versions json");
|
|
let wallet = value
|
|
.as_array()
|
|
.and_then(|rows| {
|
|
rows.iter()
|
|
.find(|row| row.get("name").and_then(serde_json::Value::as_str) == Some("wallet"))
|
|
})
|
|
.expect("wallet tool row");
|
|
let wallet_command = wallet
|
|
.get("command")
|
|
.and_then(serde_json::Value::as_str)
|
|
.expect("wallet command string");
|
|
assert!(
|
|
wallet_command.contains("<PROJECT_ROOT>/lez/target/release/wallet"),
|
|
"expected scrubbed wallet command path, got: {wallet_command}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_redacts_multiline_private_key_blocks() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
"before\n-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASC\n-----END PRIVATE KEY-----\nafter\n",
|
|
)
|
|
.expect("write sequencer log");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let log_body = archive_entry_content(&entries, "logs/sequencer.log");
|
|
|
|
assert!(!log_body.contains("MIIEvQIBADANBgkqhkiG9w0BAQEFAASC"));
|
|
assert!(!log_body.contains("-----BEGIN PRIVATE KEY-----"));
|
|
assert!(!log_body.contains("-----END PRIVATE KEY-----"));
|
|
assert!(
|
|
log_body.contains("[REDACTED SENSITIVE LINE]"),
|
|
"expected redaction markers, got: {log_body}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_redacts_url_userinfo_without_colon() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
"fetch https://ghp_very_secret_token@github.com/logos/repo\n",
|
|
)
|
|
.expect("write sequencer log");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let log_body = archive_entry_content(&entries, "logs/sequencer.log");
|
|
|
|
assert!(!log_body.contains("ghp_very_secret_token"));
|
|
assert!(
|
|
log_body.contains("https://[REDACTED]@github.com/logos/repo"),
|
|
"expected token-style userinfo redaction, got: {log_body}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn report_tail_keeps_only_last_requested_lines() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
"line-1\nline-2\nline-3\nline-4\n",
|
|
)
|
|
.expect("write sequencer log");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.arg("--tail")
|
|
.arg("2")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let log_body = archive_entry_content(&entries, "logs/sequencer.log");
|
|
|
|
assert!(!log_body.contains("line-1"));
|
|
assert!(!log_body.contains("line-2"));
|
|
assert!(log_body.contains("line-3"));
|
|
assert!(log_body.contains("line-4"));
|
|
}
|
|
|
|
#[test]
|
|
fn report_default_archive_names_are_unique_for_fast_repeats() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archives = list_report_archives(&temp.path().join(".scaffold/reports"));
|
|
assert_eq!(
|
|
archives.len(),
|
|
2,
|
|
"expected two report archives from back-to-back runs, got: {:?}",
|
|
archives
|
|
);
|
|
assert_ne!(archives[0], archives[1], "archive paths must be unique");
|
|
}
|
|
|
|
#[test]
|
|
fn report_fails_outside_project_with_project_scoped_message() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn report_skips_unreadable_optional_file_and_keeps_succeeding() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
fs::create_dir(temp.path().join(".env.local")).expect("make .env.local unreadable as dir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("report")
|
|
.assert()
|
|
.success();
|
|
|
|
let archive_path = find_single_report_archive(&temp.path().join(".scaffold/reports"));
|
|
let entries = read_report_archive_entries(&archive_path);
|
|
let manifest = archive_entry_content(&entries, "manifest.json");
|
|
assert!(
|
|
manifest.contains("project/env.local"),
|
|
"manifest should record skipped env summary, got: {manifest}"
|
|
);
|
|
assert!(
|
|
manifest.contains("failed to read .env.local"),
|
|
"manifest should include skip reason, got: {manifest}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_status_json_is_parseable() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("status")
|
|
.arg("--json")
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert!(value.get("tracked_pid").is_some());
|
|
assert!(value.get("listener_present").is_some());
|
|
assert!(value.get("ownership").is_some());
|
|
assert!(value.get("ready").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_logs_json_tails_and_is_parseable() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(
|
|
temp.path().join(".scaffold/logs/sequencer.log"),
|
|
"line one\nline two\nline three\n",
|
|
)
|
|
.expect("write sequencer log");
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["localnet", "logs", "--tail", "2", "--json"])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(value.get("exists").and_then(|v| v.as_bool()), Some(true));
|
|
assert_eq!(value.get("tail").and_then(|v| v.as_u64()), Some(2));
|
|
let lines = value
|
|
.get("lines")
|
|
.and_then(|v| v.as_array())
|
|
.expect("lines array");
|
|
assert_eq!(
|
|
lines.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>(),
|
|
vec!["line two", "line three"],
|
|
"should return the last two lines"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_logs_json_reports_missing_log_without_failing() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["localnet", "logs", "--json"])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(value.get("exists").and_then(|v| v.as_bool()), Some(false));
|
|
assert_eq!(
|
|
value.get("lines").and_then(|v| v.as_array()).map(Vec::len),
|
|
Some(0),
|
|
"missing log should yield an empty lines array"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_logs_json_treats_whitespace_only_log_as_empty() {
|
|
// A log file that exists but holds only newlines/whitespace must report
|
|
// exists=true with an empty `lines` array — `content.lines()` on "\n\n"
|
|
// would otherwise yield ["", ""]. Mirrors the plain-text "empty" branch.
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
fs::create_dir_all(temp.path().join(".scaffold/logs")).expect("create logs dir");
|
|
fs::write(temp.path().join(".scaffold/logs/sequencer.log"), "\n\n \n")
|
|
.expect("write whitespace-only log");
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["localnet", "logs", "--json"])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(value.get("exists").and_then(|v| v.as_bool()), Some(true));
|
|
assert_eq!(
|
|
value.get("lines").and_then(|v| v.as_array()).map(Vec::len),
|
|
Some(0),
|
|
"whitespace-only log must yield an empty lines array, not [\"\"]"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_json_outputs_machine_readable_report() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_scaffold_toml(temp.path(), &lez_path);
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("doctor")
|
|
.arg("--json")
|
|
.assert();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert!(value.get("status").is_some());
|
|
assert!(value.get("summary").is_some());
|
|
assert!(value.get("checks").is_some());
|
|
assert!(value.get("next_steps").is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_uses_password_env_override_for_wallet_health() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("EXPECT_PASSWORD", "override-pass")
|
|
.env("LOGOS_SCAFFOLD_WALLET_PASSWORD", "override-pass")
|
|
.arg("doctor")
|
|
.arg("--json")
|
|
.assert();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
let checks = value
|
|
.get("checks")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("checks array");
|
|
let wallet_usability = checks
|
|
.iter()
|
|
.find(|check| {
|
|
check.get("name").and_then(serde_json::Value::as_str) == Some("wallet usability")
|
|
})
|
|
.expect("wallet usability check present");
|
|
|
|
assert_eq!(
|
|
wallet_usability
|
|
.get("status")
|
|
.and_then(serde_json::Value::as_str),
|
|
Some("pass")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_sequencer_port_and_wallet_network_track_custom_localnet_port() {
|
|
// PR #84 made deploy/wallet derive the sequencer URL from `[localnet]
|
|
// port`. Doctor's port probe and wallet-network heuristic must track the
|
|
// same configured port rather than a hardcoded 3040 — otherwise a project
|
|
// on a non-default port gets a misleading "sequencer port 3040" row and a
|
|
// false "wallet may point to non-local sequencer" warning.
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_wallet_stub(&lez_path);
|
|
let port = unused_local_port();
|
|
write_scaffold_toml_with_localnet(temp.path(), &lez_path, Some(port), Some(true));
|
|
write_wallet_config(temp.path(), Some(&format!("http://127.0.0.1:{port}")));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("doctor")
|
|
.arg("--json")
|
|
.assert();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
let checks = value
|
|
.get("checks")
|
|
.and_then(serde_json::Value::as_array)
|
|
.expect("checks array");
|
|
|
|
// The sequencer-port row must name the configured port, not 3040.
|
|
let port_check = checks
|
|
.iter()
|
|
.find(|c| {
|
|
c.get("name")
|
|
.and_then(serde_json::Value::as_str)
|
|
.is_some_and(|n| n.starts_with("sequencer port "))
|
|
})
|
|
.expect("sequencer port check present");
|
|
assert_eq!(
|
|
port_check.get("name").and_then(serde_json::Value::as_str),
|
|
Some(format!("sequencer port {port}").as_str()),
|
|
"port check must name the configured localnet port, got: {port_check}"
|
|
);
|
|
|
|
// Wallet config points at the configured port, so the network heuristic
|
|
// must Pass rather than warn about a non-local sequencer.
|
|
let wallet_net = checks
|
|
.iter()
|
|
.find(|c| {
|
|
c.get("name").and_then(serde_json::Value::as_str) == Some("wallet network config")
|
|
})
|
|
.expect("wallet network config check present");
|
|
assert_eq!(
|
|
wallet_net.get("status").and_then(serde_json::Value::as_str),
|
|
Some("pass"),
|
|
"wallet config on the configured localnet port must pass, got: {wallet_net}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_start_fails_when_process_exits_before_ready() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
let sequencer_bin = lez_path.join("target/release/sequencer_service");
|
|
let config_path = lez_path.join("sequencer/service/configs/debug/sequencer_config.json");
|
|
let localnet_port = unused_local_port();
|
|
fs::create_dir_all(sequencer_bin.parent().expect("parent")).expect("create dirs");
|
|
fs::create_dir_all(config_path.parent().expect("parent")).expect("create config dir");
|
|
fs::write(&config_path, format!(r#"{{"port": {localnet_port}}}"#))
|
|
.expect("write sequencer config");
|
|
fs::write(&sequencer_bin, "#!/bin/sh\nexit 1\n").expect("write fake sequencer");
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&sequencer_bin)
|
|
.expect("metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&sequencer_bin, perms).expect("chmod");
|
|
}
|
|
|
|
write_scaffold_toml_with_localnet(temp.path(), &lez_path, Some(localnet_port), Some(true));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("start")
|
|
.arg("--timeout-sec")
|
|
.arg("1")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("sequencer process exited before becoming ready")
|
|
.or(predicate::str::contains("localnet start timed out after")),
|
|
);
|
|
|
|
assert!(
|
|
!temp.path().join(".scaffold/state/localnet.state").exists(),
|
|
"state file should be cleaned after failed startup"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_start_patches_config_and_uses_configured_port() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
let sequencer_bin = lez_path.join("target/release/sequencer_service");
|
|
let config_path = lez_path.join("sequencer/service/configs/debug/sequencer_config.json");
|
|
let args_log = temp.path().join("sequencer-args.log");
|
|
let env_log = temp.path().join("sequencer-env.log");
|
|
let localnet_port = unused_local_port();
|
|
|
|
fs::create_dir_all(sequencer_bin.parent().expect("parent")).expect("create dirs");
|
|
fs::create_dir_all(config_path.parent().expect("parent")).expect("create config dir");
|
|
fs::write(&config_path, r#"{"port": 3040}"#).expect("write sequencer config");
|
|
|
|
// Fake sequencer: reads port from sequencer_config.json (like the real one),
|
|
// logs args and env for assertions.
|
|
fs::write(
|
|
&sequencer_bin,
|
|
format!(
|
|
"#!/bin/sh\nset -eu\nprintf '%s\\n' \"$@\" > '{}'\nprintf '%s' \"${{RISC0_DEV_MODE:-}}\" > '{}'\nport=$(python3 -c \"import json,sys; print(json.load(open(sys.argv[1]))['port'])\" \"$1\")\nexec python3 -m http.server \"$port\" --bind 127.0.0.1\n",
|
|
args_log.display(),
|
|
env_log.display(),
|
|
),
|
|
)
|
|
.expect("write fake sequencer");
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&sequencer_bin)
|
|
.expect("metadata")
|
|
.permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&sequencer_bin, perms).expect("chmod");
|
|
}
|
|
|
|
write_scaffold_toml_with_localnet(temp.path(), &lez_path, Some(localnet_port), Some(false));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("start")
|
|
.arg("--timeout-sec")
|
|
.arg("5")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("localnet ready"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("stop")
|
|
.assert()
|
|
.success();
|
|
|
|
// The vendored config in the lez checkout must be left untouched.
|
|
// Regression for #114: in-place mutation broke `git_clean(lez)` and
|
|
// silently disabled the cache-repo auto-reclone safety net.
|
|
let vendored_after = fs::read_to_string(&config_path).expect("read vendored config");
|
|
let vendored_json: serde_json::Value =
|
|
serde_json::from_str(&vendored_after).expect("parse vendored config");
|
|
assert_eq!(
|
|
vendored_json["port"],
|
|
serde_json::Value::Number(3040.into()),
|
|
"vendored sequencer_config.json must not be mutated, got: {vendored_after}"
|
|
);
|
|
|
|
// The patched copy lives under the project's `.scaffold/state/`. Resolve
|
|
// symlinks so the comparison against the sequencer-recorded path matches
|
|
// on macOS, where `/var` is a symlink to `/private/var` and `current_dir`
|
|
// inside the CLI returns the canonical form.
|
|
let patched_path = temp
|
|
.path()
|
|
.join(".scaffold/state/sequencer_config.json")
|
|
.canonicalize()
|
|
.expect("canonicalize patched config path");
|
|
let patched_config = fs::read_to_string(&patched_path).expect("read patched config");
|
|
let config_json: serde_json::Value =
|
|
serde_json::from_str(&patched_config).expect("parse patched config");
|
|
assert_eq!(
|
|
config_json["port"],
|
|
serde_json::Value::Number(localnet_port.into()),
|
|
"expected port in patched sequencer_config.json to be {localnet_port}, got: {patched_config}"
|
|
);
|
|
assert_eq!(
|
|
config_json["max_block_size"],
|
|
serde_json::Value::String("8 MiB".to_string()),
|
|
"expected max_block_size in patched sequencer_config.json to be widened so multi-program deploys fit, got: {patched_config}"
|
|
);
|
|
|
|
// The sequencer must have been invoked with the absolute path to the
|
|
// patched copy, not the in-repo relative path.
|
|
let args = fs::read_to_string(&args_log).expect("read args log");
|
|
assert!(
|
|
args.lines()
|
|
.any(|line| line == patched_path.to_string_lossy()),
|
|
"expected sequencer to receive patched config path {}, got args: {args}",
|
|
patched_path.display()
|
|
);
|
|
|
|
// Verify --port was NOT passed as a CLI arg
|
|
assert!(
|
|
!args.contains("--port"),
|
|
"expected --port NOT to appear in sequencer args, got: {args}"
|
|
);
|
|
|
|
let env = fs::read_to_string(&env_log).expect("read env log");
|
|
assert_eq!(env, "0", "expected risc0 dev mode override to be passed");
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_stop_outside_project_succeeds() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("stop")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("localnet not running").or(predicate::str::contains(
|
|
"listener detected on 127.0.0.1:3040",
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn localnet_stop_outside_project_with_listener_prints_hint() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
match TcpListener::bind("127.0.0.1:3040") {
|
|
Ok(listener) => {
|
|
listener
|
|
.set_nonblocking(true)
|
|
.expect("set nonblocking listener");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("stop")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("127.0.0.1:3040").and(
|
|
predicate::str::contains("Try: kill")
|
|
.or(predicate::str::contains("Try: lsof -nP -iTCP:3040")),
|
|
),
|
|
);
|
|
}
|
|
Err(_) => {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("localnet")
|
|
.arg("stop")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("localnet not running").or(
|
|
predicate::str::contains("listener detected on 127.0.0.1:3040"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_list_proxies_account_list() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("list")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("account list")
|
|
.and(predicate::str::contains("Preconfigured Public/")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_list_json_emits_structured_envelope() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["wallet", "list", "--json"])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(value.get("exit_code").and_then(|v| v.as_i64()), Some(0));
|
|
let accounts = value
|
|
.get("accounts")
|
|
.and_then(|v| v.as_array())
|
|
.expect("accounts array");
|
|
assert_eq!(accounts.len(), 2, "stub lists two accounts: {stdout}");
|
|
assert!(accounts.iter().any(|a| a
|
|
.as_str()
|
|
.is_some_and(|s| s.contains("Preconfigured Public/"))));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_json_dry_run_is_structured() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args([
|
|
"wallet",
|
|
"topup",
|
|
"--address",
|
|
VALID_PUBLIC_ADDRESS,
|
|
"--dry-run",
|
|
"--json",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(
|
|
value.get("status").and_then(|v| v.as_str()),
|
|
Some("dry_run")
|
|
);
|
|
assert!(value
|
|
.get("address")
|
|
.and_then(|v| v.as_str())
|
|
.is_some_and(|s| s.contains("Public/")));
|
|
assert_eq!(
|
|
value.get("method").and_then(|v| v.as_str()),
|
|
Some("pinata faucet claim")
|
|
);
|
|
// Stable schema: `tx` key is always present (null for a dry run).
|
|
assert!(value.get("tx").is_some_and(serde_json::Value::is_null));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_json_success_reports_tx() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args([
|
|
"wallet",
|
|
"topup",
|
|
"--address",
|
|
VALID_PUBLIC_ADDRESS,
|
|
"--json",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(
|
|
value.get("status").and_then(|v| v.as_str()),
|
|
Some("success")
|
|
);
|
|
assert_eq!(
|
|
value.get("tx").and_then(|v| v.as_str()),
|
|
Some("pinata-topup-hash"),
|
|
"stub emits tx_hash=pinata-topup-hash"
|
|
);
|
|
// stdout must be a single clean JSON object — no `$ <cmd>` echo lines.
|
|
assert!(
|
|
stdout.trim_start().starts_with('{'),
|
|
"json mode must not echo commands before the object: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_json_connectivity_failure_categorizes_reason() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_FAIL_CONNECT", "1")
|
|
.args([
|
|
"wallet",
|
|
"topup",
|
|
"--address",
|
|
VALID_PUBLIC_ADDRESS,
|
|
"--json",
|
|
])
|
|
.assert()
|
|
.failure();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid json");
|
|
|
|
assert_eq!(value.get("status").and_then(|v| v.as_str()), Some("error"));
|
|
assert_eq!(
|
|
value.get("reason").and_then(|v| v.as_str()),
|
|
Some("connectivity")
|
|
);
|
|
// Error objects carry the same attempt context as success/pending, so
|
|
// consumers don't lose what was attempted on failure.
|
|
assert!(value
|
|
.get("address")
|
|
.and_then(|v| v.as_str())
|
|
.is_some_and(|s| s.contains("Public/")));
|
|
assert_eq!(
|
|
value.get("method").and_then(|v| v.as_str()),
|
|
Some("pinata faucet claim")
|
|
);
|
|
assert_eq!(
|
|
value.get("network").and_then(|v| v.as_str()),
|
|
Some("http://127.0.0.1:3040")
|
|
);
|
|
// Stable schema: `tx` is always present (null in the error case).
|
|
assert!(value.get("tx").is_some_and(serde_json::Value::is_null));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_passthrough_account_list_works() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("--")
|
|
.arg("account")
|
|
.arg("list")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("account list")
|
|
.and(predicate::str::contains("Preconfigured Public/")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_passthrough_requires_args_after_double_dash() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("--")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"wallet passthrough requires at least one argument after `--`",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_dry_run_planned_network_uses_localnet_port_without_wallet_sequencer_addr() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_wallet_stub(&lez_path);
|
|
let port = unused_local_port();
|
|
write_scaffold_toml_with_localnet(temp.path(), &lez_path, Some(port), Some(true));
|
|
write_wallet_config(temp.path(), None);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.arg("--dry-run")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(format!(
|
|
"planned network: local sequencer (http://127.0.0.1:{port})"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_dry_run_renders_pinata_claim_command() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.arg("--dry-run")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("dry-run: wallet topup command will not be executed")
|
|
.and(predicate::str::contains(
|
|
"planned preflight: check destination wallet initialization",
|
|
))
|
|
.and(predicate::str::contains("auth-transfer init --account-id"))
|
|
.and(predicate::str::contains("pinata claim --to"))
|
|
.and(predicate::str::contains(
|
|
"planned method: pinata faucet claim",
|
|
)),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_runs_pinata_claim_with_explicit_address() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("pinata claim --to Public/")
|
|
.and(predicate::str::contains("wallet topup complete")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_initializes_when_account_uninitialized_before_pinata() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_ACCOUNT_STATE", "uninitialized")
|
|
.env("TOPUP_GUARD_REQUIRE_INIT", "1")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("wallet topup complete"));
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
let init_pos = stdout
|
|
.find("auth-transfer init --account-id Public/")
|
|
.expect("init command should be present");
|
|
let pinata_pos = stdout
|
|
.find("pinata claim --to Public/")
|
|
.expect("pinata command should be present");
|
|
assert!(
|
|
init_pos < pinata_pos,
|
|
"auth-transfer init must run before pinata claim, got output:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_skips_init_when_account_already_initialized() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_ACCOUNT_STATE", "initialized")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success();
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
assert!(
|
|
!stdout.contains("auth-transfer init --account-id"),
|
|
"init command must not run for initialized accounts, got output:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("pinata claim --to Public/"),
|
|
"pinata claim should still run, got output:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_preflight_failure_blocks_pinata() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_PREFLIGHT_FAIL", "1")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"wallet topup failed while checking account initialization",
|
|
));
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
assert!(
|
|
!stdout.contains("pinata claim --to"),
|
|
"pinata must not run when preflight fails, got output:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_uses_password_env_override() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("EXPECT_PASSWORD", "override-pass")
|
|
.env("LOGOS_SCAFFOLD_WALLET_PASSWORD", "override-pass")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("wallet topup complete"));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_falls_back_to_default_password_when_env_missing() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("EXPECT_PASSWORD", DEFAULT_WALLET_PASSWORD)
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("wallet topup complete"));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_uses_default_wallet_when_address_is_omitted() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("default")
|
|
.arg("set")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success();
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("pinata claim --to Public/"));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_errors_when_address_and_default_are_missing() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("wallet topup requires a destination address")
|
|
.and(predicate::str::contains("logos-scaffold wallet list")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_rejects_invalid_address() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg("abc")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("invalid address format `abc`")
|
|
.and(predicate::str::contains("Accepted formats")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_shows_sequencer_hint_on_connectivity_failure() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_FAIL_CONNECT", "1")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("sequencer appears unavailable")
|
|
.and(predicate::str::contains("logos-scaffold localnet start"))
|
|
.and(predicate::str::contains("Another project's sequencer")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_init_connectivity_failure_shows_sequencer_hint() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_ACCOUNT_STATE", "uninitialized")
|
|
.env("TOPUP_INIT_FAIL_CONNECT", "1")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("sequencer appears unavailable")
|
|
.and(predicate::str::contains("logos-scaffold localnet start"))
|
|
.and(predicate::str::contains("Another project's sequencer")),
|
|
);
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
assert!(
|
|
!stdout.contains("pinata claim --to"),
|
|
"pinata must not run when init fails with connectivity error, got output:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_continues_when_init_reports_already_initialized() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_ACCOUNT_STATE", "uninitialized")
|
|
.env("TOPUP_INIT_FAIL_ALREADY", "1")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("destination already initialized; continuing")
|
|
.and(predicate::str::contains("wallet topup complete")),
|
|
);
|
|
|
|
let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout");
|
|
assert!(
|
|
stdout.contains("pinata claim --to Public/"),
|
|
"pinata should run after tolerated init race, got output:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_timeout_exits_non_zero_with_pending_status() {
|
|
// C3: confirmation timeout means we don't actually know whether the
|
|
// submission landed. Surface that to the agent via a non-zero exit and
|
|
// an explicit `status: pending` in the message, instead of the previous
|
|
// soft-success that tricked retry loops into thinking the topup was done.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("TOPUP_FAIL_TIMEOUT", "1")
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("wallet topup submitted, but confirmation timed out")
|
|
.and(predicate::str::contains("status: pending")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_topup_fails_outside_project_with_project_scoped_message() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("topup")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_default_set_persists_normalized_address_positional() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("default")
|
|
.arg("set")
|
|
.arg(VALID_ACCOUNT_ID)
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("default wallet updated"));
|
|
|
|
let state_path = temp.path().join(".scaffold/state/wallet.state");
|
|
let state = fs::read_to_string(state_path).expect("read wallet.state");
|
|
assert_eq!(state, format!("default_address={VALID_PUBLIC_ADDRESS}\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn wallet_default_set_accepts_flag_form() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("wallet")
|
|
.arg("default")
|
|
.arg("set")
|
|
.arg("--address")
|
|
.arg(VALID_PUBLIC_ADDRESS)
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("default wallet updated"));
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_unknown_program_lists_available_programs() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), None);
|
|
write_guest_program(temp.path(), "alpha");
|
|
write_guest_program(temp.path(), "beta");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("missing")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("unknown program `missing`")
|
|
.and(predicate::str::contains("alpha"))
|
|
.and(predicate::str::contains("beta")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_single_program_submits_successfully() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("OK hello submitted")
|
|
.and(predicate::str::contains(
|
|
"Submission confirmed by wallet exit status",
|
|
))
|
|
.and(predicate::str::contains("Succeeded: 1"))
|
|
.and(predicate::str::contains("Failed: 0"))
|
|
.and(predicate::str::contains("reachability probe failed").not()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_uses_password_env_override() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("EXPECT_PASSWORD", "override-pass")
|
|
.env("LOGOS_SCAFFOLD_WALLET_PASSWORD", "override-pass")
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("OK hello submitted"));
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_missing_binary_shows_build_hint() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "hello");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.failure()
|
|
.stdout(
|
|
predicate::str::contains("missing binary")
|
|
.and(predicate::str::contains("logos-scaffold build")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_continues_and_summarizes_mixed_results() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "alpha");
|
|
write_guest_program(temp.path(), "beta");
|
|
write_guest_binary(temp.path(), "alpha");
|
|
write_guest_binary(temp.path(), "beta");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("FAIL_PROGRAM", "beta.bin")
|
|
.arg("deploy")
|
|
.assert()
|
|
.failure()
|
|
.stdout(
|
|
predicate::str::contains("OK alpha submitted")
|
|
.and(predicate::str::contains("FAIL beta deployment failed"))
|
|
.and(predicate::str::contains("Succeeded: 1"))
|
|
.and(predicate::str::contains("Failed: 1")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_preflight_targets_localnet_port_when_wallet_omits_sequencer_addr() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let lez_path = temp.path().join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_wallet_stub(&lez_path);
|
|
let port = unused_local_port();
|
|
write_scaffold_toml_with_localnet(temp.path(), &lez_path, Some(port), Some(true));
|
|
write_wallet_config(temp.path(), None);
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
|
|
let url = format!("http://127.0.0.1:{port}");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("cannot deploy programs")
|
|
.and(predicate::str::contains(&url))
|
|
.and(predicate::str::contains("sequencer appears unavailable")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_shows_hint_when_sequencer_is_unreachable_with_configured_addr() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:65535"));
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("sequencer appears unavailable")
|
|
.and(predicate::str::contains("logos-scaffold localnet start"))
|
|
.and(predicate::str::contains("Another project's sequencer")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_shows_hint_when_sequencer_is_unreachable_with_fallback_addr() {
|
|
// Wallet omits `sequencer_addr`; deploy falls back to `http://127.0.0.1:<localnet.port>`
|
|
// (default 3040 when `[localnet]` is omitted). Skip if something is already listening.
|
|
if TcpStream::connect("127.0.0.1:3040").is_ok() {
|
|
return;
|
|
}
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), None);
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("sequencer appears unavailable"));
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_prints_program_id_from_vendored_spel() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
let expected = expected_stub_program_id("hello.bin");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("OK hello submitted").and(predicate::str::contains(format!(
|
|
" program_id: {expected}"
|
|
))),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_plain_output_has_single_program_id_line_per_program() {
|
|
// The OK block carries program_id per program; the Summary block no
|
|
// longer repeats it. With N programs deployed, exactly N lines should
|
|
// match `^ program_id: …` so a single grep/awk pattern works whether
|
|
// the user deploys one program or many.
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "alpha");
|
|
write_guest_program(temp.path(), "beta");
|
|
write_guest_binary(temp.path(), "alpha");
|
|
write_guest_binary(temp.path(), "beta");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.output()
|
|
.expect("run deploy");
|
|
assert!(output.status.success(), "deploy must succeed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let count = stdout
|
|
.lines()
|
|
.filter(|line| line.starts_with(" program_id: "))
|
|
.count();
|
|
assert_eq!(
|
|
count, 2,
|
|
"expected exactly one ` program_id:` line per deployed program (2 total); got {count} in:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_program_path_json_includes_program_id() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
let custom = temp.path().join("custom.bin");
|
|
fs::write(&custom, b"stub-program-bin").expect("write custom bin");
|
|
let expected = expected_stub_program_id("custom.bin");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("--program-path")
|
|
.arg(&custom)
|
|
.arg("--json")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(format!(
|
|
"\"program_id\":\"{expected}\""
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_program_id_unavailable_when_spel_missing() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "hello");
|
|
write_guest_binary(temp.path(), "hello");
|
|
// Remove the spel stub the helper placed; deploy should still succeed
|
|
// and print the "unavailable" hint instead of a hex.
|
|
fs::remove_file(temp.path().join("spel/target/release/spel")).expect("remove spel stub");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("hello")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("OK hello submitted")
|
|
.and(predicate::str::contains("program_id: unavailable"))
|
|
.and(predicate::str::contains("logos-scaffold setup")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_json_output_is_pure_json_no_command_echo() {
|
|
// --json must produce a single JSON line on stdout; the wallet
|
|
// subprocess command-echo (`$ .../wallet deploy-program …`) belongs
|
|
// off-channel so consumers can pipe directly to `jq`.
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
let custom = temp.path().join("custom.bin");
|
|
fs::write(&custom, b"stub-program-bin").expect("write custom bin");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("--program-path")
|
|
.arg(&custom)
|
|
.arg("--json")
|
|
.output()
|
|
.expect("run deploy --json");
|
|
assert!(output.status.success(), "deploy --json must succeed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let trimmed = stdout.trim();
|
|
assert!(
|
|
trimmed.starts_with('{') && trimmed.ends_with('}'),
|
|
"stdout must be pure JSON; got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("$ "),
|
|
"stdout must not carry the `$ <cmd>` command echo; got:\n{stdout}"
|
|
);
|
|
assert_eq!(
|
|
stdout.lines().filter(|l| !l.is_empty()).count(),
|
|
1,
|
|
"stdout must be a single non-empty JSON line; got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_multi_program_json_emits_deploys_array() {
|
|
// Auto-discovery deploy with --json must produce one JSON line of the
|
|
// shape `{"deploys":[{...},{...}]}` — pure JSON, no command echoes,
|
|
// no human-readable text. One entry per program with program_id
|
|
// populated when extraction succeeds.
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "alpha");
|
|
write_guest_program(temp.path(), "beta");
|
|
write_guest_binary(temp.path(), "alpha");
|
|
write_guest_binary(temp.path(), "beta");
|
|
let alpha_id = expected_stub_program_id("alpha.bin");
|
|
let beta_id = expected_stub_program_id("beta.bin");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("--json")
|
|
.output()
|
|
.expect("run deploy --json");
|
|
assert!(
|
|
output.status.success(),
|
|
"multi-program deploy --json must succeed"
|
|
);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let trimmed = stdout.trim();
|
|
assert!(
|
|
trimmed.starts_with("{\"deploys\":[") && trimmed.ends_with("]}"),
|
|
"stdout must be a {{\"deploys\":[…]}} JSON line; got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("$ "),
|
|
"stdout must not carry the `$ <cmd>` command echo; got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("OK ") && !stdout.contains("Note:") && !stdout.contains("Summary:"),
|
|
"stdout must not carry human-readable text; got:\n{stdout}"
|
|
);
|
|
assert_eq!(
|
|
stdout.lines().filter(|l| !l.is_empty()).count(),
|
|
1,
|
|
"stdout must be a single JSON line; got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
trimmed.contains(&format!("\"program_id\":\"{alpha_id}\"")),
|
|
"missing alpha program_id in:\n{trimmed}"
|
|
);
|
|
assert!(
|
|
trimmed.contains(&format!("\"program_id\":\"{beta_id}\"")),
|
|
"missing beta program_id in:\n{trimmed}"
|
|
);
|
|
assert_eq!(
|
|
trimmed.matches("\"status\":\"submitted\"").count(),
|
|
2,
|
|
"expected exactly two submitted entries in:\n{trimmed}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_json_output_parses_as_valid_json() {
|
|
// Pin the structural contract: --json stdout must round-trip through
|
|
// serde_json::from_str. Cleanliness is already covered by
|
|
// `deploy_json_output_is_pure_json_no_command_echo`; this is the
|
|
// structural counterpart.
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
let custom = temp.path().join("custom.bin");
|
|
fs::write(&custom, b"stub-program-bin").expect("write custom bin");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("--program-path")
|
|
.arg(&custom)
|
|
.arg("--json")
|
|
.output()
|
|
.expect("run deploy --json");
|
|
assert!(output.status.success(), "deploy --json must succeed");
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let trimmed = stdout.trim();
|
|
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(trimmed).expect("single-program --json output must be valid JSON");
|
|
let obj = value
|
|
.as_object()
|
|
.expect("top-level value must be an object");
|
|
assert_eq!(
|
|
obj.get("status").and_then(|v| v.as_str()),
|
|
Some("submitted"),
|
|
"missing or wrong status in: {trimmed}"
|
|
);
|
|
assert!(
|
|
obj.get("program").and_then(|v| v.as_str()).is_some(),
|
|
"missing program field in: {trimmed}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_multi_program_json_output_parses_as_valid_json() {
|
|
// Multi-program counterpart: parse the wrapper and assert the deploys
|
|
// array shape via serde_json (rather than substring matching).
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "alpha");
|
|
write_guest_program(temp.path(), "beta");
|
|
write_guest_binary(temp.path(), "alpha");
|
|
write_guest_binary(temp.path(), "beta");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("deploy")
|
|
.arg("--json")
|
|
.output()
|
|
.expect("run deploy --json");
|
|
assert!(
|
|
output.status.success(),
|
|
"multi-program deploy --json must succeed"
|
|
);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let trimmed = stdout.trim();
|
|
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(trimmed).expect("multi-program --json output must be valid JSON");
|
|
let obj = value
|
|
.as_object()
|
|
.expect("top-level value must be an object");
|
|
let deploys = obj
|
|
.get("deploys")
|
|
.and_then(|v| v.as_array())
|
|
.expect("missing or non-array `deploys` field");
|
|
assert_eq!(
|
|
deploys.len(),
|
|
2,
|
|
"expected exactly two deploy entries; got: {trimmed}"
|
|
);
|
|
for entry in deploys {
|
|
let entry_obj = entry.as_object().expect("each entry must be an object");
|
|
assert!(
|
|
entry_obj.contains_key("status"),
|
|
"entry missing status: {entry}"
|
|
);
|
|
assert!(
|
|
entry_obj.contains_key("program"),
|
|
"entry missing program: {entry}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_multi_program_json_records_failed_entries_with_error() {
|
|
// Mixed-result deploy under --json: failed programs appear in the
|
|
// array with status:"failed" and an `error` field, no program_id.
|
|
// Process exits non-zero (any failure fails the deploy).
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
write_guest_program(temp.path(), "alpha");
|
|
write_guest_program(temp.path(), "beta");
|
|
write_guest_binary(temp.path(), "alpha");
|
|
write_guest_binary(temp.path(), "beta");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("FAIL_PROGRAM", "beta.bin")
|
|
.arg("deploy")
|
|
.arg("--json")
|
|
.output()
|
|
.expect("run deploy --json with one failure");
|
|
assert!(
|
|
!output.status.success(),
|
|
"must exit non-zero on any failure"
|
|
);
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let trimmed = stdout.trim();
|
|
assert!(
|
|
trimmed.starts_with("{\"deploys\":[") && trimmed.ends_with("]}"),
|
|
"stdout must remain a JSON object even with failures; got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
trimmed.contains("\"status\":\"submitted\"") && trimmed.contains("\"program\":\"alpha\""),
|
|
"alpha must be present and submitted in:\n{trimmed}"
|
|
);
|
|
assert!(
|
|
trimmed.contains("\"status\":\"failed\"") && trimmed.contains("\"program\":\"beta\""),
|
|
"beta must be present and failed in:\n{trimmed}"
|
|
);
|
|
assert!(
|
|
trimmed.contains("\"error\":\""),
|
|
"failed entries must carry an `error` field; got:\n{trimmed}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_json_omits_tx_when_wallet_returns_none() {
|
|
// Wallet stub today emits a `tx_hash=…` line. To prove the JSON shape
|
|
// omits `tx` when no value is available, set WALLET_NO_TX=1 — the stub
|
|
// honors that and skips the tx line. Result: presence of the `tx` key
|
|
// should now imply a real value (not `null`).
|
|
let temp = tempdir().expect("tempdir");
|
|
let rpc = RpcStub::start();
|
|
setup_wallet_project(temp.path(), Some(&rpc.url));
|
|
let custom = temp.path().join("custom.bin");
|
|
fs::write(&custom, b"stub-program-bin").expect("write custom bin");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("WALLET_NO_TX", "1")
|
|
.arg("deploy")
|
|
.arg("--program-path")
|
|
.arg(&custom)
|
|
.arg("--json")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("\"tx\":").not());
|
|
}
|
|
|
|
#[test]
|
|
fn spel_proxy_forwards_args_to_vendored_binary() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("spel")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("methods/guest/foo.bin")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("ImageID (hex bytes):"));
|
|
}
|
|
|
|
#[test]
|
|
fn spel_proxy_works_with_leading_quiet_flag() {
|
|
// Suppressed copilot comment on PR #86: `spel_passthrough_args` was
|
|
// hard-coded to look at args[1], so `lgs -q spel -- ...` skipped the
|
|
// passthrough entirely and fell through to clap (which has no `spel`
|
|
// subcommand). Mirrors `wallet_passthrough_works_with_leading_quiet_flag`
|
|
// for symmetry with the other passthrough.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("--quiet")
|
|
.arg("spel")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("methods/guest/foo.bin")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("ImageID (hex bytes):"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("-q")
|
|
.arg("spel")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("methods/guest/foo.bin")
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn spel_proxy_accepts_quiet_between_subcommand_and_separator() {
|
|
// Copilot review on PR #86: same global-quiet composition fix as for
|
|
// wallet passthrough. `lgs spel -q -- inspect ...` must be intercepted
|
|
// (not handed to clap, which has no `spel` subcommand) and must
|
|
// suppress the `$ <spel-bin>` echo on stdout.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("spel")
|
|
.arg("-q")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("methods/guest/foo.bin")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("ImageID (hex bytes):"));
|
|
let stdout = String::from_utf8_lossy(&assert.get_output().stdout).into_owned();
|
|
assert!(
|
|
!stdout.contains("$ "),
|
|
"in-between -q must suppress the `$ <cmd>` echo line, got:\n{stdout}"
|
|
);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("spel")
|
|
.arg("--quiet")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("methods/guest/foo.bin")
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[test]
|
|
fn spel_proxy_forwards_nonzero_exit_code() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.env("SPEL_FAIL", "1")
|
|
.arg("spel")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("foo.bin")
|
|
.assert()
|
|
.failure();
|
|
}
|
|
|
|
#[test]
|
|
fn spel_proxy_hints_when_binary_missing() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
fs::remove_file(temp.path().join("spel/target/release/spel")).expect("remove spel stub");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("spel")
|
|
.arg("--")
|
|
.arg("inspect")
|
|
.arg("foo.bin")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("logos-scaffold setup"));
|
|
}
|
|
|
|
#[test]
|
|
fn spel_proxy_requires_args_after_dash_dash() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("spel")
|
|
.arg("--")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"spel passthrough requires at least one argument",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn spel_without_dash_dash_suggests_passthrough_form() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("spel")
|
|
.arg("inspect")
|
|
.arg("foo.bin")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("Did you mean")
|
|
.and(predicate::str::contains("logos-scaffold spel -- inspect")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn spel_help_accepts_clap_help_spellings() {
|
|
for help_arg in ["--help", "-h", "help", "-?"] {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("spel")
|
|
.arg(help_arg)
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("Forward arguments to the project-vendored `spel` binary")
|
|
.and(predicate::str::contains("logos-scaffold spel --")),
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_help_lists_setup_install_launch_and_profile() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("basecamp")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("setup")
|
|
.and(predicate::str::contains("install"))
|
|
.and(predicate::str::contains("launch"))
|
|
.and(predicate::str::contains("profile")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_install_help_has_no_source_flags() {
|
|
// `install` is pure replay: no `--flake` / `--path` / `--profile`. Those
|
|
// live on `basecamp modules` (sole writer of the captured source set).
|
|
let out = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("basecamp")
|
|
.arg("install")
|
|
.arg("--help")
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
!stdout.contains("--path"),
|
|
"install must not take --path: {stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("--flake"),
|
|
"install must not take --flake: {stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("--profile"),
|
|
"install must not take --profile: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_modules_help_lists_flags() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("basecamp")
|
|
.arg("modules")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("--path")
|
|
.and(predicate::str::contains("--flake"))
|
|
.and(predicate::str::contains("--show")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_launch_help_requires_profile_argument() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("PROFILE"));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_launch_without_profile_errors() {
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.assert()
|
|
.failure();
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_develop_help_lists_module_and_dev_shell() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.args(["basecamp", "develop", "--help"])
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("MODULE").and(predicate::str::contains("--dev-shell")));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_develop_unknown_module_errors_with_known_list() {
|
|
// Module lookup runs before the `nix` presence check, so this is
|
|
// deterministic in CI (no Nix needed): an unknown module name fails fast
|
|
// and lists the captured modules.
|
|
let temp = tempdir().expect("tempdir");
|
|
let toml = format!(
|
|
"{MINIMAL_SCAFFOLD_TOML}\n[modules.swap_module]\nflake = \"github:logos-co/swap-module#lgx\"\nrole = \"project\"\n"
|
|
);
|
|
fs::write(temp.path().join("scaffold.toml"), toml).expect("write scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["basecamp", "develop", "nonexistent"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("no module `nonexistent`")
|
|
.and(predicate::str::contains("swap_module")),
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn self_test_run_logged_success_shape() {
|
|
// Hidden `self-test run-logged` hook drives `run_logged` against a
|
|
// trivial subprocess (`true`). We assert the visible output shape
|
|
// so future reshapes of `run_logged` don't silently regress the UX.
|
|
let temp = tempdir().expect("tempdir");
|
|
let log = temp.path().join("self-test.log");
|
|
let out = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.args([
|
|
"self-test",
|
|
"run-logged",
|
|
"--log",
|
|
log.to_str().unwrap(),
|
|
"--step",
|
|
"self-test success",
|
|
])
|
|
.assert()
|
|
.success();
|
|
let stdout = String::from_utf8_lossy(&out.get_output().stdout).into_owned();
|
|
assert!(
|
|
stdout.contains("self-test success"),
|
|
"expected the step label to appear, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("tip: tail -f"),
|
|
"expected tail-f hint on the default logged path, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("✓"),
|
|
"expected a ✓ finalization line on success, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
log.exists(),
|
|
"expected the log file to be written at {}",
|
|
log.display()
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn self_test_run_logged_failure_shape() {
|
|
// Failure path must bail (non-zero exit) and name the log path.
|
|
let temp = tempdir().expect("tempdir");
|
|
let log = temp.path().join("self-test.log");
|
|
let assert = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.args([
|
|
"self-test",
|
|
"run-logged",
|
|
"--log",
|
|
log.to_str().unwrap(),
|
|
"--step",
|
|
"self-test failure",
|
|
"--fail",
|
|
])
|
|
.assert()
|
|
.failure();
|
|
let stderr = String::from_utf8_lossy(&assert.get_output().stderr).into_owned();
|
|
assert!(
|
|
stderr.contains("self-test failure") && stderr.contains("failed with"),
|
|
"failure bail must name the step and exit status, got:\n{stderr}"
|
|
);
|
|
assert!(
|
|
stderr.contains(&log.display().to_string()),
|
|
"failure bail must reference the captured log path, got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn self_test_run_logged_print_output_streams_and_echoes_command() {
|
|
// Under --print-output the shape is different: no `tip: tail -f`, has
|
|
// `running: <cmd>`, still has ✓/✗ with duration.
|
|
let temp = tempdir().expect("tempdir");
|
|
let log = temp.path().join("self-test.log");
|
|
let out = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.args([
|
|
"self-test",
|
|
"run-logged",
|
|
"--log",
|
|
log.to_str().unwrap(),
|
|
"--step",
|
|
"self-test streamed",
|
|
"--print-output",
|
|
])
|
|
.assert()
|
|
.success();
|
|
let stdout = String::from_utf8_lossy(&out.get_output().stdout).into_owned();
|
|
assert!(
|
|
stdout.contains("running: ") && stdout.contains("true"),
|
|
"--print-output must echo the command, got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
!stdout.contains("tip: tail -f"),
|
|
"--print-output must not print the log-tail hint (no capture), got:\n{stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("✓"),
|
|
"expected ✓ finalization under --print-output, got:\n{stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_docs_prints_compatibility_rules_anywhere() {
|
|
// LLM-driven discoverability: `basecamp docs` must work without a
|
|
// scaffold project (no scaffold.toml needed) so an agent can retrieve
|
|
// the rules before setting anything up. Asserts on the top-level
|
|
// heading + the Quick checklist anchor so trivial drift in the doc
|
|
// body doesn't break the test.
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["basecamp", "docs"])
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("# Basecamp Module Requirements")
|
|
.and(predicate::str::contains("Quick checklist")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_help_carries_docs_breadcrumb() {
|
|
// Every basecamp subcommand's --help should mention `basecamp docs`
|
|
// so an LLM exploring the CLI finds the compatibility doc without
|
|
// filesystem context.
|
|
for subcommand in &[
|
|
"setup",
|
|
"modules",
|
|
"install",
|
|
"launch",
|
|
"build-portable",
|
|
"doctor",
|
|
] {
|
|
let out = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.args(["basecamp", subcommand, "--help"])
|
|
.output()
|
|
.unwrap();
|
|
let stdout = String::from_utf8_lossy(&out.stdout);
|
|
assert!(
|
|
stdout.contains("basecamp docs"),
|
|
"{subcommand} --help must reference `basecamp docs`; got:\n{stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_profile_subcommand_does_not_exist() {
|
|
// The `basecamp profile` subcommand was removed (KISS — feature was a
|
|
// stub that returned "not yet implemented" and polluted --help). If
|
|
// someone re-adds it, they should land a real implementation at the
|
|
// same time.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.args(["basecamp", "profile"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("unrecognized subcommand").or(
|
|
predicate::str::contains("error: unrecognized").or(predicate::str::contains("Usage:")),
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_setup_outside_project_errors() {
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("setup")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_install_outside_project_errors() {
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("install")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_build_portable_outside_project_errors() {
|
|
// `build-portable` takes no source-set flags; the outside-project check
|
|
// runs first regardless.
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["basecamp", "build-portable"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_build_portable_rejects_flake_flag() {
|
|
// `--flake` was removed from build-portable; source set lives in state,
|
|
// managed by `basecamp modules`. Clap must reject the flag.
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["basecamp", "build-portable", "--flake", ".#lgx-portable"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("--flake"));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_build_portable_rejects_dry_run_flag() {
|
|
// `build-portable` is non-destructive and does not accept `--dry-run`.
|
|
// Clap must reject the flag with a usage error.
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["basecamp", "build-portable", "--dry-run"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("--dry-run"));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_build_portable_inside_empty_project_emits_hint_to_capture_first() {
|
|
// With state.project_sources empty, build-portable must refuse cleanly
|
|
// and point the user at `basecamp modules` (it never auto-discovers on
|
|
// its own; that's modules' job).
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML)
|
|
.expect("write scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["basecamp", "build-portable"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("basecamp modules"));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_install_before_setup_emits_hint() {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML)
|
|
.expect("write scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("install")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"basecamp not set up yet; run: logos-scaffold basecamp setup",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_launch_before_setup_emits_hint() {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML)
|
|
.expect("write scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.arg("alice")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"basecamp not set up yet; run: logos-scaffold basecamp setup",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_launch_setup_hint_takes_precedence_over_profile_validation() {
|
|
// Pins the order of the two early gates: when the state is missing AND the
|
|
// profile name is invalid, the user must see the setup hint — not a
|
|
// confusing "unknown profile" error for a profile that doesn't exist yet
|
|
// anyway. A future refactor that reorders the checks should fail this test.
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML)
|
|
.expect("write scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.arg("charlie")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("basecamp not set up yet"))
|
|
.stderr(predicate::str::contains("unknown profile").not());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn basecamp_launch_rejects_unknown_profile() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let project = temp.path();
|
|
fs::write(project.join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML).expect("write scaffold.toml");
|
|
|
|
// Fake a completed setup so we get past the first gate and reach profile
|
|
// validation. Launch never reaches `exec` because the profile check fails first.
|
|
let state_dir = project.join(".scaffold/state");
|
|
fs::create_dir_all(&state_dir).expect("mkdir state");
|
|
fs::write(state_dir.join("basecamp.state"), fake_basecamp_state()).expect("write state");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(project)
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.arg("charlie")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("unknown profile `charlie`"));
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn basecamp_launch_bails_when_no_modules_captured() {
|
|
// launch scrubs and replays the captured module set. If [basecamp.modules]
|
|
// is empty, the replay is silently a no-op — the profile comes up with
|
|
// zero modules installed, which violates the clean-slate guarantee.
|
|
// Surface as an error with a concrete hint.
|
|
let temp = tempdir().expect("tempdir");
|
|
let project = temp.path();
|
|
let scaffold_toml = format!(
|
|
"{MINIMAL_SCAFFOLD_TOML}\n\
|
|
[repos.basecamp]\n\
|
|
source = \"https://example/basecamp\"\n\
|
|
pin = \"deadbeef\"\n\
|
|
build = \"nix-flake\"\n\
|
|
attr = \"app\"\n\
|
|
\n\
|
|
[basecamp]\n\
|
|
port_base = 60000\n\
|
|
port_stride = 10\n"
|
|
);
|
|
fs::write(project.join("scaffold.toml"), scaffold_toml).expect("write scaffold.toml");
|
|
|
|
// Fake a completed setup and a seeded alice profile dir so launch passes
|
|
// the early gates and reaches the modules check.
|
|
let state_dir = project.join(".scaffold/state");
|
|
fs::create_dir_all(&state_dir).expect("mkdir state");
|
|
fs::write(state_dir.join("basecamp.state"), fake_basecamp_state()).expect("write state");
|
|
fs::create_dir_all(project.join(".scaffold/basecamp/profiles/alice")).expect("mkdir profile");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(project)
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.arg("alice")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("basecamp modules")
|
|
.and(predicate::str::contains("destructive by default").not()),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_launch_outside_project_errors() {
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("basecamp")
|
|
.arg("launch")
|
|
.arg("alice")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
fn find_single_report_archive(reports_dir: &Path) -> PathBuf {
|
|
let mut archives = list_report_archives(reports_dir);
|
|
assert_eq!(
|
|
archives.len(),
|
|
1,
|
|
"expected exactly one report archive in {}",
|
|
reports_dir.display()
|
|
);
|
|
archives.remove(0)
|
|
}
|
|
|
|
fn list_report_archives(reports_dir: &Path) -> Vec<PathBuf> {
|
|
let mut archives = fs::read_dir(reports_dir)
|
|
.expect("read reports dir")
|
|
.filter_map(Result::ok)
|
|
.map(|entry| entry.path())
|
|
.filter(|path| {
|
|
path.is_file()
|
|
&& path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(|name| name.ends_with(".tar.gz"))
|
|
.unwrap_or(false)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
archives.sort();
|
|
archives
|
|
}
|
|
|
|
fn read_report_archive_entries(archive_path: &Path) -> Vec<(String, String)> {
|
|
let file = fs::File::open(archive_path).expect("open report archive");
|
|
let decoder = GzDecoder::new(file);
|
|
let mut archive = Archive::new(decoder);
|
|
|
|
let mut entries = Vec::new();
|
|
for entry in archive.entries().expect("archive entries") {
|
|
let mut entry = entry.expect("archive entry");
|
|
if !entry.header().entry_type().is_file() {
|
|
continue;
|
|
}
|
|
|
|
let path = entry
|
|
.path()
|
|
.expect("archive entry path")
|
|
.display()
|
|
.to_string();
|
|
let mut body = String::new();
|
|
entry.read_to_string(&mut body).expect("archive entry body");
|
|
entries.push((path, body));
|
|
}
|
|
|
|
entries
|
|
}
|
|
|
|
fn archive_entry_exists(entries: &[(String, String)], suffix: &str) -> bool {
|
|
entries.iter().any(|(path, _)| path.ends_with(suffix))
|
|
}
|
|
|
|
fn archive_entry_content<'a>(entries: &'a [(String, String)], suffix: &str) -> &'a str {
|
|
entries
|
|
.iter()
|
|
.find(|(path, _)| path.ends_with(suffix))
|
|
.map(|(_, body)| body.as_str())
|
|
.unwrap_or_else(|| panic!("archive missing expected entry suffix `{suffix}`"))
|
|
}
|
|
|
|
fn write_scaffold_toml(project_root: &Path, lez_path: &Path) {
|
|
write_scaffold_toml_with_localnet(project_root, lez_path, None, None);
|
|
}
|
|
|
|
fn write_scaffold_toml_with_localnet(
|
|
project_root: &Path,
|
|
lez_path: &Path,
|
|
localnet_port: Option<u16>,
|
|
risc0_dev_mode: Option<bool>,
|
|
) {
|
|
let spel_path = project_root.join("spel");
|
|
// Schema 0.2.0: no url, no [basecamp] / [basecamp.modules.*],
|
|
// path is set on lez/spel for back-compat (tests need a literal path
|
|
// to a local stub repo).
|
|
let mut content = format!(
|
|
"[scaffold]\nversion = \"0.2.0\"\ncache_root = \"{}\"\n\n[repos.lez]\nsource = \"https://github.com/logos-blockchain/logos-execution-zone.git\"\npin = \"{}\"\npath = \"{}\"\n\n[repos.spel]\nsource = \"https://github.com/logos-co/spel.git\"\npin = \"{}\"\npath = \"{}\"\n\n[wallet]\nhome_dir = \".scaffold/wallet\"\n",
|
|
project_root.join("cache").display(),
|
|
TEST_PIN,
|
|
lez_path.display(),
|
|
TEST_PIN,
|
|
spel_path.display(),
|
|
);
|
|
|
|
if let Some(port) = localnet_port {
|
|
let risc0_dev_mode = risc0_dev_mode.unwrap_or(true);
|
|
content.push_str(&format!(
|
|
"\n[localnet]\nport = {port}\nrisc0_dev_mode = {risc0_dev_mode}\n"
|
|
));
|
|
}
|
|
|
|
fs::write(project_root.join("scaffold.toml"), content).expect("write scaffold.toml");
|
|
}
|
|
|
|
fn unused_local_port() -> u16 {
|
|
TcpListener::bind("127.0.0.1:0")
|
|
.expect("bind unused local port")
|
|
.local_addr()
|
|
.expect("local addr")
|
|
.port()
|
|
}
|
|
|
|
fn setup_wallet_project(project_root: &Path, sequencer_addr: Option<&str>) {
|
|
let lez_path = project_root.join("lez");
|
|
fs::create_dir_all(&lez_path).expect("create lez path");
|
|
write_wallet_stub(&lez_path);
|
|
write_spel_stub(&project_root.join("spel"));
|
|
write_scaffold_toml(project_root, &lez_path);
|
|
write_wallet_config(project_root, sequencer_addr);
|
|
}
|
|
|
|
/// Place a minimal `spel` stub at `<spel_path>/target/release/spel`. It
|
|
/// emits the canonical ` ImageID (hex bytes): <hex>` line that
|
|
/// `extract_program_id` parses, with a deterministic-per-binary hash so
|
|
/// tests can assert exact values. Honors:
|
|
/// `SPEL_FAIL=1` → exit non-zero (proxy exit-code test)
|
|
/// `SPEL_PROGRAM_ID_FAIL=<n>` → exit non-zero only when arg2 basename
|
|
/// contains `<n>` (program-id-unavailable
|
|
/// test for one program out of several).
|
|
fn write_spel_stub(spel_path: &Path) {
|
|
let bin = spel_path.join("target/release/spel");
|
|
fs::create_dir_all(bin.parent().expect("parent")).expect("mkdir spel target");
|
|
let script = r#"#!/bin/sh
|
|
set -eu
|
|
|
|
if [ "${SPEL_FAIL:-0}" = "1" ]; then
|
|
echo "spel stub: forced failure" >&2
|
|
exit 7
|
|
fi
|
|
|
|
if [ "$#" -ge 2 ] && { [ "$1" = "program-id" ] || [ "$1" = "inspect" ]; }; then
|
|
bin_path="$2"
|
|
bin_name="$(basename "$bin_path")"
|
|
if [ -n "${SPEL_PROGRAM_ID_FAIL:-}" ]; then
|
|
case "$bin_name" in
|
|
*"$SPEL_PROGRAM_ID_FAIL"*)
|
|
echo "spel stub: forced program-id failure for $bin_name" >&2
|
|
exit 8
|
|
;;
|
|
esac
|
|
fi
|
|
# Build a deterministic 64-char hex ID directly from the basename: emit
|
|
# each byte as two lowercase hex digits, repeat the resulting string until
|
|
# it is at least 64 chars, then truncate. No CRC or hash table — both the
|
|
# stub and `expected_stub_program_id` in Rust use the same trivial formula.
|
|
hex="$(printf '%s' "$bin_name" | od -An -vtx1 | tr -d ' \n')"
|
|
while [ "${#hex}" -lt 64 ]; do
|
|
hex="$hex$hex"
|
|
done
|
|
full="$(printf '%s' "$hex" | cut -c1-64)"
|
|
printf ' ImageID (hex bytes): %s\n' "$full"
|
|
exit 0
|
|
fi
|
|
|
|
# Echo other invocations so the proxy test can verify args reach the binary.
|
|
echo "spel stub invoked: $*"
|
|
exit 0
|
|
"#;
|
|
fs::write(&bin, script).expect("write spel stub");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&bin).expect("stub metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&bin, perms).expect("chmod spel stub");
|
|
}
|
|
}
|
|
|
|
/// Recompute the program ID the spel stub emits for a given binary basename.
|
|
/// Mirrors the trivial hex-of-basename formula in `write_spel_stub` (`od -tx1`
|
|
/// then repeat to 64 chars) so tests can assert exact hex without invoking
|
|
/// the stub themselves. No hash table, no polynomial — if the formula ever
|
|
/// drifts, both sides of the equation are right next to each other.
|
|
fn expected_stub_program_id(bin_name: &str) -> String {
|
|
let mut hex: String = bin_name.bytes().map(|b| format!("{b:02x}")).collect();
|
|
while hex.len() < 64 {
|
|
hex = format!("{hex}{hex}");
|
|
}
|
|
hex.truncate(64);
|
|
hex
|
|
}
|
|
|
|
fn write_wallet_config(project_root: &Path, sequencer_addr: Option<&str>) {
|
|
let wallet_home = project_root.join(".scaffold/wallet");
|
|
fs::create_dir_all(&wallet_home).expect("create wallet home");
|
|
let path = wallet_home.join("wallet_config.json");
|
|
|
|
let mut value = serde_json::json!({
|
|
"initial_accounts": [
|
|
{ "Public": { "account_id": VALID_ACCOUNT_ID } }
|
|
]
|
|
});
|
|
if let Some(addr) = sequencer_addr {
|
|
value["sequencer_addr"] = serde_json::Value::String(addr.to_string());
|
|
}
|
|
|
|
let text = serde_json::to_string_pretty(&value).expect("wallet config json");
|
|
fs::write(path, text).expect("write wallet config");
|
|
}
|
|
|
|
fn write_wallet_stub(lez_path: &Path) {
|
|
let path = lez_path.join("target/release/wallet");
|
|
fs::create_dir_all(path.parent().expect("parent")).expect("create wallet binary dir");
|
|
let script = r#"#!/bin/sh
|
|
set -eu
|
|
|
|
require_password_if_configured() {
|
|
if [ "${EXPECT_PASSWORD:-}" = "" ]; then
|
|
return 0
|
|
fi
|
|
IFS= read -r provided || true
|
|
if [ "$provided" != "$EXPECT_PASSWORD" ]; then
|
|
echo "password mismatch" >&2
|
|
exit 3
|
|
fi
|
|
}
|
|
|
|
if [ "$#" -ge 2 ] && [ "$1" = "account" ] && [ "$2" = "list" ]; then
|
|
echo "Preconfigured Public/6iArKUXxhUJqS7kCaPNhwMWt3ro71PDyBj7jwAyE2VQV"
|
|
echo "/ Public/8zxWNm1qh6FLsJpVBuDxdxcTm55qHPgFEdqJpPVu1fuy"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$#" -ge 2 ] && [ "$1" = "account" ] && [ "$2" = "get" ]; then
|
|
if [ "${TOPUP_PREFLIGHT_FAIL:-0}" = "1" ]; then
|
|
echo "simulated account get failure" >&2
|
|
exit 4
|
|
fi
|
|
if [ "${TOPUP_ACCOUNT_STATE:-initialized}" = "uninitialized" ]; then
|
|
echo "Account is Uninitialized"
|
|
else
|
|
echo "Account state: Initialized"
|
|
fi
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$#" -ge 3 ] && [ "$1" = "auth-transfer" ] && [ "$2" = "init" ] && [ "$3" = "--account-id" ]; then
|
|
require_password_if_configured
|
|
if [ "${TOPUP_INIT_FAIL_CONNECT:-0}" = "1" ]; then
|
|
echo "connection refused" >&2
|
|
exit 1
|
|
fi
|
|
if [ "${TOPUP_INIT_FAIL_ALREADY:-0}" = "1" ]; then
|
|
echo "Error: Account must be uninitialized" >&2
|
|
exit 1
|
|
fi
|
|
marker_path="${NSSA_WALLET_HOME_DIR:-.}/.topup-init-ran"
|
|
: > "$marker_path"
|
|
echo "init ok"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$#" -ge 2 ] && [ "$1" = "pinata" ] && [ "$2" = "claim" ]; then
|
|
require_password_if_configured
|
|
if [ "${TOPUP_GUARD_REQUIRE_INIT:-0}" = "1" ]; then
|
|
marker_path="${NSSA_WALLET_HOME_DIR:-.}/.topup-init-ran"
|
|
if [ ! -f "$marker_path" ]; then
|
|
echo "pinata called before init" >&2
|
|
exit 9
|
|
fi
|
|
fi
|
|
if [ "${TOPUP_FAIL_CONNECT:-0}" = "1" ]; then
|
|
echo "connection refused" >&2
|
|
exit 1
|
|
fi
|
|
if [ "${TOPUP_FAIL_TIMEOUT:-0}" = "1" ]; then
|
|
echo "Error: Transaction not found in preconfigured amount of blocks" >&2
|
|
exit 1
|
|
fi
|
|
echo "tx_hash=pinata-topup-hash"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$#" -ge 2 ] && [ "$1" = "deploy-program" ]; then
|
|
require_password_if_configured
|
|
bin_path="$2"
|
|
bin_name="$(basename "$bin_path")"
|
|
if [ "${FAIL_PROGRAM:-}" = "$bin_name" ]; then
|
|
echo "simulated deploy failure for $bin_name" >&2
|
|
exit 2
|
|
fi
|
|
# WALLET_NO_TX=1: simulate the wallet not surfacing a tx identifier so
|
|
# tests can assert the deploy JSON omits the `tx` key when None.
|
|
if [ "${WALLET_NO_TX:-0}" = "1" ]; then
|
|
exit 0
|
|
fi
|
|
echo "tx_hash=deploy-$bin_name"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$#" -ge 1 ] && [ "$1" = "--version" ]; then
|
|
echo "wallet stub 0.1.0"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$#" -ge 1 ] && [ "$1" = "check-health" ]; then
|
|
require_password_if_configured
|
|
echo "ok"
|
|
exit 0
|
|
fi
|
|
|
|
echo "unsupported wallet invocation: $*" >&2
|
|
exit 2
|
|
"#;
|
|
fs::write(&path, script).expect("write wallet stub");
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut perms = fs::metadata(&path).expect("metadata").permissions();
|
|
perms.set_mode(0o755);
|
|
fs::set_permissions(&path, perms).expect("chmod");
|
|
}
|
|
}
|
|
|
|
fn write_guest_program(project_root: &Path, name: &str) {
|
|
let dir = project_root.join("methods/guest/src/bin");
|
|
fs::create_dir_all(&dir).expect("create guest program dir");
|
|
fs::write(dir.join(format!("{name}.rs")), "fn main() {}\n").expect("write guest source");
|
|
}
|
|
|
|
fn write_guest_binary(project_root: &Path, name: &str) {
|
|
let dir = project_root.join(GUEST_BIN_REL_PATH);
|
|
fs::create_dir_all(&dir).expect("create guest binary dir");
|
|
fs::write(dir.join(format!("{name}.bin")), b"stub-program-bin").expect("write guest binary");
|
|
}
|
|
|
|
struct RpcStub {
|
|
url: String,
|
|
stop: Arc<AtomicBool>,
|
|
addr: String,
|
|
handle: Option<thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl RpcStub {
|
|
fn start() -> Self {
|
|
let listener = TcpListener::bind("127.0.0.1:0").expect("bind rpc stub");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
let addr_str = addr.to_string();
|
|
listener
|
|
.set_nonblocking(true)
|
|
.expect("set nonblocking rpc stub");
|
|
|
|
let stop = Arc::new(AtomicBool::new(false));
|
|
let stop_flag = Arc::clone(&stop);
|
|
|
|
let handle = thread::spawn(move || {
|
|
while !stop_flag.load(Ordering::Relaxed) {
|
|
match listener.accept() {
|
|
Ok((mut stream, _)) => {
|
|
respond_last_block(&mut stream);
|
|
}
|
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
|
thread::sleep(Duration::from_millis(10));
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
|
|
Self {
|
|
url: format!("http://{addr_str}"),
|
|
stop,
|
|
addr: addr_str,
|
|
handle: Some(handle),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for RpcStub {
|
|
fn drop(&mut self) {
|
|
self.stop.store(true, Ordering::Relaxed);
|
|
let _ = TcpStream::connect(&self.addr);
|
|
if let Some(handle) = self.handle.take() {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn respond_last_block(stream: &mut TcpStream) {
|
|
let mut buf = [0_u8; 4096];
|
|
let _ = stream.read(&mut buf);
|
|
|
|
let body = r#"{"jsonrpc":"2.0","result":123,"id":1}"#;
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
|
body.len(),
|
|
body
|
|
);
|
|
let _ = stream.write_all(response.as_bytes());
|
|
let _ = stream.flush();
|
|
}
|
|
|
|
#[test]
|
|
fn lgs_help_usage_line_shows_lgs() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage: lgs"));
|
|
}
|
|
|
|
#[test]
|
|
fn logos_scaffold_help_usage_line_shows_logos_scaffold() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage: logos-scaffold"));
|
|
}
|
|
|
|
#[test]
|
|
fn lgs_help_subcommand_uses_invoked_bin_name() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.arg("help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage: lgs"));
|
|
}
|
|
|
|
#[test]
|
|
fn logos_scaffold_help_subcommand_uses_invoked_bin_name() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage: logos-scaffold"));
|
|
}
|
|
|
|
#[test]
|
|
fn lgs_no_args_uses_invoked_bin_name() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage: lgs"));
|
|
}
|
|
|
|
#[test]
|
|
fn logos_scaffold_no_args_uses_invoked_bin_name() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Usage: logos-scaffold"));
|
|
}
|
|
|
|
#[test]
|
|
fn lgs_and_logos_scaffold_advertise_same_subcommands() {
|
|
let subcommands = [
|
|
"create", "new", "setup", "build", "deploy", "wallet", "localnet", "doctor", "report",
|
|
];
|
|
|
|
let lgs_help = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.arg("--help")
|
|
.output()
|
|
.expect("run lgs --help");
|
|
let ls_help = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("--help")
|
|
.output()
|
|
.expect("run logos-scaffold --help");
|
|
|
|
assert!(lgs_help.status.success(), "lgs --help failed");
|
|
assert!(ls_help.status.success(), "logos-scaffold --help failed");
|
|
|
|
let lgs_out = String::from_utf8_lossy(&lgs_help.stdout);
|
|
let ls_out = String::from_utf8_lossy(&ls_help.stdout);
|
|
|
|
for sub in subcommands {
|
|
assert!(lgs_out.contains(sub), "lgs help missing subcommand `{sub}`");
|
|
assert!(
|
|
ls_out.contains(sub),
|
|
"logos-scaffold help missing subcommand `{sub}`"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn lgs_and_logos_scaffold_version_match() {
|
|
let lgs_ver = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.arg("--version")
|
|
.output()
|
|
.expect("run lgs --version");
|
|
let ls_ver = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("--version")
|
|
.output()
|
|
.expect("run logos-scaffold --version");
|
|
|
|
assert!(lgs_ver.status.success());
|
|
assert!(ls_ver.status.success());
|
|
|
|
let lgs_version_number = String::from_utf8_lossy(&lgs_ver.stdout)
|
|
.split_whitespace()
|
|
.last()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
let ls_version_number = String::from_utf8_lossy(&ls_ver.stdout)
|
|
.split_whitespace()
|
|
.last()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
|
|
assert_eq!(
|
|
lgs_version_number, ls_version_number,
|
|
"version numbers differ"
|
|
);
|
|
assert!(!lgs_version_number.is_empty(), "version number is empty");
|
|
}
|
|
|
|
#[test]
|
|
fn completions_bash_prints_script_covering_both_bin_names() {
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "bash"])
|
|
.output()
|
|
.expect("run lgs completions bash");
|
|
assert!(output.status.success(), "expected success exit");
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
|
|
assert!(
|
|
stdout.contains("complete -F _lgs"),
|
|
"missing primary binding: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("logos-scaffold"),
|
|
"missing alias binding: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_zsh_compdef_directive_covers_both_names() {
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "zsh"])
|
|
.output()
|
|
.expect("run lgs completions zsh");
|
|
assert!(output.status.success(), "expected success exit");
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
|
|
let compdef_headers = stdout.matches("#compdef").count();
|
|
assert_eq!(
|
|
compdef_headers, 1,
|
|
"expected exactly one #compdef header, got {compdef_headers}: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.starts_with("#compdef lgs logos-scaffold\n"),
|
|
"expected `#compdef lgs logos-scaffold` directive so autoload \
|
|
registers both names at compinit time; got head: {:?}",
|
|
stdout.lines().next()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_cover_basecamp_subcommands() {
|
|
// Regression guard: the completion scripts are generated from the full
|
|
// clap command tree, so every `basecamp` sub-subcommand should appear in
|
|
// both bash and zsh output. If someone accidentally hides a basecamp
|
|
// subcommand (e.g. gating it with clap feature flags), shell-tab users
|
|
// would silently lose completion — this test catches that at build time.
|
|
const BASECAMP_SUBS: &[&str] = &[
|
|
"setup",
|
|
"modules",
|
|
"install",
|
|
"launch",
|
|
"build-portable",
|
|
"doctor",
|
|
"docs",
|
|
];
|
|
|
|
for shell in &["bash", "zsh"] {
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", shell])
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("run lgs completions {shell}: {e}"));
|
|
assert!(output.status.success(), "expected success exit for {shell}");
|
|
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
|
|
assert!(
|
|
stdout.contains("basecamp"),
|
|
"{shell} completion script missing `basecamp`:\n{stdout}"
|
|
);
|
|
for sub in BASECAMP_SUBS {
|
|
assert!(
|
|
stdout.contains(sub),
|
|
"{shell} completion script missing `basecamp {sub}`; \
|
|
stdout head:\n{}",
|
|
stdout.lines().take(40).collect::<Vec<_>>().join("\n")
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn completions_bash_output_is_syntax_clean() {
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "bash"])
|
|
.output()
|
|
.expect("run lgs completions bash");
|
|
assert!(output.status.success(), "expected success exit");
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("lgs.bash");
|
|
fs::write(&path, &output.stdout).expect("write script");
|
|
|
|
let syntax = std::process::Command::new("bash")
|
|
.arg("-n")
|
|
.arg(&path)
|
|
.output()
|
|
.expect("bash -n");
|
|
assert!(
|
|
syntax.status.success(),
|
|
"bash -n failed: {}",
|
|
String::from_utf8_lossy(&syntax.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_zsh_output_is_syntax_clean() {
|
|
if std::process::Command::new("zsh")
|
|
.arg("--version")
|
|
.output()
|
|
.is_err()
|
|
{
|
|
eprintln!("skipping: zsh not available");
|
|
return;
|
|
}
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "zsh"])
|
|
.output()
|
|
.expect("run lgs completions zsh");
|
|
assert!(output.status.success(), "expected success exit");
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
let path = temp.path().join("_lgs");
|
|
fs::write(&path, &output.stdout).expect("write script");
|
|
|
|
let syntax = std::process::Command::new("zsh")
|
|
.arg("-n")
|
|
.arg(&path)
|
|
.output()
|
|
.expect("zsh -n");
|
|
assert!(
|
|
syntax.status.success(),
|
|
"zsh -n failed: {}",
|
|
String::from_utf8_lossy(&syntax.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_unsupported_shell_errors() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "fish"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("fish"));
|
|
}
|
|
|
|
#[test]
|
|
fn completions_bash_help_shows_install_instructions() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "bash", "--help"])
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("bash-completion/completions/lgs")
|
|
.and(predicate::str::contains("logos-scaffold")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_zsh_help_shows_install_instructions() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "zsh", "--help"])
|
|
.assert()
|
|
.success()
|
|
.stdout(
|
|
predicate::str::contains("~/.zfunc/_lgs")
|
|
.and(predicate::str::contains("oh-my-zsh"))
|
|
.and(predicate::str::contains("compinit")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn completions_missing_shell_arg_errors() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.arg("completions")
|
|
.assert()
|
|
.failure();
|
|
}
|
|
|
|
#[test]
|
|
fn completions_does_not_write_filesystem() {
|
|
let temp = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.args(["completions", "bash"])
|
|
.assert()
|
|
.success();
|
|
|
|
let entries: Vec<_> = fs::read_dir(temp.path())
|
|
.expect("read tempdir")
|
|
.filter_map(|e| e.ok())
|
|
.collect();
|
|
assert!(
|
|
entries.is_empty(),
|
|
"completions must not write to cwd, found: {:?}",
|
|
entries.iter().map(|e| e.path()).collect::<Vec<_>>()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn init_creates_scaffold_toml_and_dirs() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success();
|
|
|
|
assert!(
|
|
temp.path().join("scaffold.toml").exists(),
|
|
"scaffold.toml missing"
|
|
);
|
|
assert!(
|
|
temp.path().join(".scaffold/state").is_dir(),
|
|
".scaffold/state missing"
|
|
);
|
|
assert!(
|
|
temp.path().join(".scaffold/logs").is_dir(),
|
|
".scaffold/logs missing"
|
|
);
|
|
|
|
let gitignore = fs::read_to_string(temp.path().join(".gitignore")).expect("read .gitignore");
|
|
assert!(
|
|
gitignore.lines().any(|l| l.trim() == ".scaffold"),
|
|
".gitignore must contain .scaffold, got: {gitignore:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn init_refreshes_skills_when_already_at_v0_2_0_scaffold_toml() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let scaffold_path = temp.path().join("scaffold.toml");
|
|
// First, run init to lay down a fresh v0.2.0 file plus the AI skill set.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success();
|
|
let original_scaffold = fs::read_to_string(&scaffold_path).expect("read scaffold.toml");
|
|
|
|
// Second invocation must succeed (no migration needed) and refresh the
|
|
// shipped skill set, but it must not rewrite scaffold.toml.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("already at schema"))
|
|
.stdout(predicate::str::contains("AI skills refreshed"));
|
|
|
|
let after_scaffold = fs::read_to_string(&scaffold_path).expect("read scaffold.toml");
|
|
assert_eq!(
|
|
after_scaffold, original_scaffold,
|
|
"init must not overwrite an already-migrated scaffold.toml"
|
|
);
|
|
|
|
for skill in [
|
|
"lgs-cli",
|
|
"lez-template",
|
|
"lez-framework-template",
|
|
"basecamp",
|
|
] {
|
|
assert!(
|
|
temp.path()
|
|
.join(format!(".claude/skills/{skill}/SKILL.md"))
|
|
.is_file(),
|
|
"claude skill `{skill}` must be present after re-init"
|
|
);
|
|
assert!(
|
|
temp.path()
|
|
.join(format!(".cursor/rules/{skill}.mdc"))
|
|
.is_file(),
|
|
"cursor rule `{skill}` must be present after re-init"
|
|
);
|
|
}
|
|
assert!(
|
|
temp.path().join("AGENTS.md").is_file(),
|
|
"AGENTS.md must be present after re-init"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn init_migrates_pre_v0_2_0_scaffold_toml_in_place() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let scaffold_path = temp.path().join("scaffold.toml");
|
|
let original = "# user comment\n[scaffold]\nversion = \"0.1.0\"\n\n[repos.lez]\nurl = \"u\"\nsource = \"u\"\npath = \"p\"\npin = \"abc\"\n";
|
|
fs::write(&scaffold_path, original).expect("seed scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("migrated to schema v0.2.0"));
|
|
|
|
let after = fs::read_to_string(&scaffold_path).expect("read scaffold.toml");
|
|
assert!(
|
|
after.contains("# user comment"),
|
|
"user comments must be preserved; got:\n{after}"
|
|
);
|
|
assert!(
|
|
after.contains("version = \"0.2.0\""),
|
|
"[scaffold].version must be bumped; got:\n{after}"
|
|
);
|
|
assert!(
|
|
after.contains("[repos.spel]"),
|
|
"[repos.spel] must be appended; got:\n{after}"
|
|
);
|
|
assert!(
|
|
!after.contains("url ="),
|
|
"url field must be stripped; got:\n{after}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn init_splits_basecamp_lgpm_flake_into_repos_lgpm() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let scaffold_path = temp.path().join("scaffold.toml");
|
|
// Pre-0.2.0: lgpm pinned via [basecamp].lgpm_flake (canonical
|
|
// github:owner/repo/<sha>#attr form). After `lgs init`, the flake ref
|
|
// must be split into source/pin/attr under [repos.lgpm].
|
|
let original = r#"[scaffold]
|
|
version = "0.1.0"
|
|
|
|
[repos.lez]
|
|
source = "https://example/lez.git"
|
|
pin = "deadbeef"
|
|
|
|
[repos.spel]
|
|
source = "https://example/spel.git"
|
|
pin = "deadbeef"
|
|
|
|
[basecamp]
|
|
pin = "deadbeef"
|
|
source = "https://example/basecamp"
|
|
lgpm_flake = "github:logos-co/logos-package-manager/cafef00dcafef00dcafef00dcafef00dcafef00d#cli"
|
|
port_base = 60000
|
|
port_stride = 10
|
|
|
|
[wallet]
|
|
home_dir = ".scaffold/wallet"
|
|
"#;
|
|
fs::write(&scaffold_path, original).expect("seed scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success();
|
|
|
|
let after = fs::read_to_string(&scaffold_path).expect("read scaffold.toml");
|
|
assert!(
|
|
!after.contains("lgpm_flake"),
|
|
"lgpm_flake key must be removed; got:\n{after}"
|
|
);
|
|
assert!(
|
|
after.contains("[repos.lgpm]"),
|
|
"[repos.lgpm] section must be appended; got:\n{after}"
|
|
);
|
|
assert!(
|
|
after.contains("github:logos-co/logos-package-manager"),
|
|
"[repos.lgpm].source must carry the flake source; got:\n{after}"
|
|
);
|
|
assert!(
|
|
after.contains("cafef00dcafef00dcafef00dcafef00dcafef00d"),
|
|
"[repos.lgpm].pin must be the SHA from the flake ref; got:\n{after}"
|
|
);
|
|
assert!(
|
|
after.contains("attr = \"cli\""),
|
|
"[repos.lgpm].attr must carry the flake attr; got:\n{after}"
|
|
);
|
|
|
|
// Re-running init on the now-migrated config must succeed (skills get
|
|
// refreshed) and report that no migration was needed.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("already at schema"))
|
|
.stdout(predicate::str::contains("AI skills refreshed"));
|
|
}
|
|
|
|
#[test]
|
|
fn init_appends_gitignore_once() {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join(".gitignore"), "target\n.scaffold\nother\n")
|
|
.expect("seed .gitignore");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success();
|
|
|
|
let gitignore = fs::read_to_string(temp.path().join(".gitignore")).expect("read .gitignore");
|
|
let scaffold_count = gitignore
|
|
.lines()
|
|
.filter(|l| l.trim() == ".scaffold")
|
|
.count();
|
|
assert_eq!(
|
|
scaffold_count, 1,
|
|
".gitignore must contain .scaffold exactly once, got: {gitignore:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn init_hint_uses_invoked_bin_name() {
|
|
let temp_lgs = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp_lgs.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Run 'lgs setup'"));
|
|
|
|
let temp_long = tempdir().expect("tempdir");
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp_long.path())
|
|
.arg("init")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("Run 'logos-scaffold setup'"));
|
|
}
|
|
|
|
#[test]
|
|
fn completions_zsh_registers_both_names_in_pristine_shell() {
|
|
if std::process::Command::new("zsh")
|
|
.arg("--version")
|
|
.output()
|
|
.is_err()
|
|
{
|
|
eprintln!("skipping: zsh not available");
|
|
return;
|
|
}
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.args(["completions", "zsh"])
|
|
.output()
|
|
.expect("run lgs completions zsh");
|
|
assert!(output.status.success(), "expected success exit");
|
|
|
|
let temp = tempdir().expect("tempdir");
|
|
let fpath_dir = temp.path().join("fpath");
|
|
fs::create_dir_all(&fpath_dir).expect("mkdir fpath");
|
|
fs::write(fpath_dir.join("_lgs"), &output.stdout).expect("write _lgs");
|
|
|
|
// Run a pristine zsh (-f skips rc files) with only our fpath plus
|
|
// system completion functions, then verify both names are registered
|
|
// at compinit time — not deferred to first tab.
|
|
let script = format!(
|
|
"fpath=({} /usr/share/zsh/*/functions); \
|
|
autoload -Uz compinit && compinit -u -d {}/zcompdump; \
|
|
print \"lgs=${{_comps[lgs]:-MISSING}}\"; \
|
|
print \"logos-scaffold=${{_comps[logos-scaffold]:-MISSING}}\"",
|
|
fpath_dir.display(),
|
|
temp.path().display(),
|
|
);
|
|
|
|
let zsh_output = std::process::Command::new("zsh")
|
|
.args(["-f", "-c", &script])
|
|
.output()
|
|
.expect("run pristine zsh");
|
|
let stdout = String::from_utf8_lossy(&zsh_output.stdout);
|
|
assert!(
|
|
stdout.contains("lgs=_lgs"),
|
|
"expected lgs to be registered, got: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("logos-scaffold=_lgs"),
|
|
"expected logos-scaffold to be registered at compinit time, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
/// Pre-0.2.0 scaffold.toml with all the legacy signals (url field on
|
|
/// [repos.lez], [basecamp.modules.*], [basecamp].lgpm_flake/pin/source).
|
|
/// Every non-init command must hard-fail when this is present and the
|
|
/// migration hint must point at `init`.
|
|
const PRE_V0_2_0_SCAFFOLD_TOML: &str = r#"# preserved comment
|
|
[scaffold]
|
|
version = "0.1.0"
|
|
cache_root = "cache"
|
|
|
|
[repos.lez]
|
|
url = "https://example/lez.git"
|
|
source = "https://example/lez.git"
|
|
path = "lez"
|
|
pin = "deadbeef"
|
|
|
|
[wallet]
|
|
home_dir = ".scaffold/wallet"
|
|
|
|
[framework]
|
|
kind = "default"
|
|
version = "0.1.0"
|
|
|
|
[framework.idl]
|
|
spec = "lssa-idl/0.1.0"
|
|
path = "idl"
|
|
|
|
[localnet]
|
|
port = 3040
|
|
risc0_dev_mode = true
|
|
|
|
[basecamp]
|
|
pin = "deadbeef"
|
|
source = "https://example/basecamp"
|
|
lgpm_flake = "github:logos-co/logos-package-manager/cafef00dcafef00dcafef00dcafef00dcafef00d#cli"
|
|
port_base = 60000
|
|
port_stride = 10
|
|
|
|
[basecamp.modules.foo]
|
|
flake = "path:./foo"
|
|
role = "project"
|
|
"#;
|
|
|
|
fn assert_pre_v0_2_0_rejection(args: &[&str]) {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), PRE_V0_2_0_SCAFFOLD_TOML)
|
|
.expect("seed pre-v0.2.0 scaffold.toml");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("run lgs {args:?}: {e}"));
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"lgs {args:?} must hard-fail on pre-0.2.0 scaffold.toml; got status {:?}, stdout:\n{}\nstderr:\n{}",
|
|
output.status,
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("init"),
|
|
"lgs {args:?} stderr must point at `init`; got:\n{stderr}",
|
|
);
|
|
|
|
let after = fs::read_to_string(temp.path().join("scaffold.toml"))
|
|
.expect("scaffold.toml still readable");
|
|
assert_eq!(
|
|
after, PRE_V0_2_0_SCAFFOLD_TOML,
|
|
"lgs {args:?} must not mutate scaffold.toml when it rejects pre-0.2.0",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn setup_hard_fails_on_pre_v0_2_0_scaffold_toml() {
|
|
assert_pre_v0_2_0_rejection(&["setup"]);
|
|
}
|
|
|
|
/// F1: `setup` must bail with the scaffold-styled circuits-prereq error
|
|
/// before any cargo work when neither `LOGOS_BLOCKCHAIN_CIRCUITS` nor
|
|
/// `~/.logos-blockchain-circuits/` is reachable. End-to-end check that the
|
|
/// `check_logos_blockchain_circuits` precheck is wired into `cmd_setup`.
|
|
#[test]
|
|
fn setup_bails_with_scaffold_styled_error_when_circuits_missing() {
|
|
let temp = tempdir().expect("tempdir");
|
|
let project = temp.path();
|
|
fs::write(project.join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML).expect("write scaffold.toml");
|
|
|
|
// Point HOME at a directory with no `.logos-blockchain-circuits` so the
|
|
// home-dir fallback also fails — otherwise the developer running the
|
|
// suite would silently pass via their real $HOME.
|
|
let fake_home = project.join("fake-home");
|
|
fs::create_dir_all(&fake_home).expect("mkdir fake home");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(project)
|
|
.env("HOME", &fake_home)
|
|
.env_remove("LOGOS_BLOCKCHAIN_CIRCUITS")
|
|
.arg("setup")
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("logos-blockchain-circuits")
|
|
.and(predicate::str::contains("$LOGOS_BLOCKCHAIN_CIRCUITS unset"))
|
|
.and(predicate::str::contains("logos-scaffold doctor"))
|
|
// Must NOT surface a raw cargo build-script panic.
|
|
.and(predicate::str::contains("logos-blockchain-pol").not())
|
|
.and(predicate::str::contains("build script").not()),
|
|
);
|
|
}
|
|
|
|
/// F4: clap's leading `error: ` is stripped before we re-wrap with anyhow,
|
|
/// so the user sees a single `error:` prefix instead of `error: error: ...`.
|
|
#[test]
|
|
fn unrecognized_subcommand_produces_single_error_prefix() {
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("bogus-subcommand")
|
|
.output()
|
|
.expect("run lgs");
|
|
assert!(
|
|
!output.status.success(),
|
|
"unrecognized subcommand must exit non-zero"
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("unrecognized subcommand"),
|
|
"stderr must surface the unrecognized-subcommand message, got:\n{stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("error: error:"),
|
|
"stderr must not double-wrap the error prefix, got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
/// Regression: commands that go through `load_project()` (basecamp, wallet,
|
|
/// deploy, report) used to wrap any `load_project` failure with the
|
|
/// "This command must be run inside a logos-scaffold project — cd into …"
|
|
/// hint. That was a lie when the user *was* inside a project but the
|
|
/// schema was stale; the only fix is `lgs init`. The wrap is removed and
|
|
/// `load_project` itself produces the right message for each branch.
|
|
#[test]
|
|
fn load_project_commands_in_stale_schema_project_show_init_hint_not_cd_hint() {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), PRE_V0_2_0_SCAFFOLD_TOML)
|
|
.expect("seed pre-v0.2.0 scaffold.toml");
|
|
|
|
for args in [
|
|
&["basecamp", "setup"][..],
|
|
&["wallet", "list"][..],
|
|
&["deploy"][..],
|
|
&["report"][..],
|
|
] {
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|e| panic!("run lgs {args:?}: {e}"));
|
|
assert!(
|
|
!output.status.success(),
|
|
"lgs {args:?} must fail on pre-0.2.0 scaffold.toml"
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("logos-scaffold init"),
|
|
"lgs {args:?} stderr must point at `init`; got:\n{stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("cd into your scaffolded project"),
|
|
"lgs {args:?} stderr must not show the cd-into-project hint when \
|
|
the user is already inside a project; got:\n{stderr}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn build_idl_fails_loudly_on_default_framework() {
|
|
// C4: explicit `build idl` against a non-lez-framework project used to
|
|
// print `Skipping…` and exit 0. Agents that piped `lgs build idl &&
|
|
// next-step` would silently carry on with no IDL produced. The fix
|
|
// bails with an explicit framework-kind message so the agent stops.
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML)
|
|
.expect("seed scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["build", "idl"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("`build idl` is only supported for `lez-framework`")
|
|
.and(predicate::str::contains("framework.kind = `default`")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_client_fails_loudly_on_default_framework() {
|
|
// C4: same as `build idl` — explicit `build client` on a default-framework
|
|
// project must fail rather than silently no-op.
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), MINIMAL_SCAFFOLD_TOML)
|
|
.expect("seed scaffold.toml");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.args(["build", "client"])
|
|
.assert()
|
|
.failure()
|
|
.stderr(
|
|
predicate::str::contains("`build client` is only supported for `lez-framework`")
|
|
.and(predicate::str::contains("framework.kind = `default`")),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_hard_fails_on_pre_v0_2_0_scaffold_toml() {
|
|
assert_pre_v0_2_0_rejection(&["build"]);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_hard_fails_on_pre_v0_2_0_scaffold_toml() {
|
|
assert_pre_v0_2_0_rejection(&["deploy"]);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_hard_fails_on_pre_v0_2_0_scaffold_toml() {
|
|
assert_pre_v0_2_0_rejection(&["doctor"]);
|
|
}
|
|
|
|
#[test]
|
|
fn report_hard_fails_on_pre_v0_2_0_scaffold_toml() {
|
|
assert_pre_v0_2_0_rejection(&["report"]);
|
|
}
|
|
|
|
#[test]
|
|
fn basecamp_setup_hard_fails_on_pre_v0_2_0_scaffold_toml() {
|
|
assert_pre_v0_2_0_rejection(&["basecamp", "setup"]);
|
|
}
|
|
|
|
#[test]
|
|
fn deploy_json_hard_fails_with_clean_stdout_on_pre_v0_2_0_scaffold_toml() {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), PRE_V0_2_0_SCAFFOLD_TOML)
|
|
.expect("seed pre-v0.2.0 scaffold.toml");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.args(["deploy", "--json"])
|
|
.output()
|
|
.expect("run lgs deploy --json");
|
|
|
|
assert!(!output.status.success(), "deploy --json must fail");
|
|
assert!(
|
|
output.stdout.is_empty(),
|
|
"deploy --json must keep stdout clean on rejection; got: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("init"),
|
|
"stderr must point at `init`; got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn doctor_json_hard_fails_with_clean_stdout_on_pre_v0_2_0_scaffold_toml() {
|
|
let temp = tempdir().expect("tempdir");
|
|
fs::write(temp.path().join("scaffold.toml"), PRE_V0_2_0_SCAFFOLD_TOML)
|
|
.expect("seed pre-v0.2.0 scaffold.toml");
|
|
|
|
let output = Command::new(assert_cmd::cargo::cargo_bin!("lgs"))
|
|
.current_dir(temp.path())
|
|
.args(["doctor", "--json"])
|
|
.output()
|
|
.expect("run lgs doctor --json");
|
|
|
|
assert!(!output.status.success(), "doctor --json must fail");
|
|
assert!(
|
|
output.stdout.is_empty(),
|
|
"doctor --json must keep stdout clean on rejection; got: {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("init"),
|
|
"stderr must point at `init`; got:\n{stderr}"
|
|
);
|
|
}
|
|
|
|
// ─── run command tests ───────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn run_help_lists_command_summary() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("run")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains(
|
|
"Build, start localnet, top up wallet, deploy, and run post-deploy hook",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn run_outside_project_fails_with_project_scoped_message() {
|
|
let temp = tempdir().expect("tempdir");
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains(
|
|
"This command must be run inside a logos-scaffold project",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn run_rejects_both_post_deploy_flags() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("run")
|
|
.arg("--post-deploy")
|
|
.arg("echo override")
|
|
.arg("--no-post-deploy")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("cannot be used with"));
|
|
}
|
|
|
|
#[test]
|
|
fn run_help_advertises_localnet_timeout_flag() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("run")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("--localnet-timeout"))
|
|
.stdout(predicate::str::contains("default: 120"));
|
|
}
|
|
|
|
#[test]
|
|
fn run_rejects_non_numeric_localnet_timeout() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("run")
|
|
.arg("--localnet-timeout")
|
|
.arg("abc")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("invalid value"));
|
|
}
|
|
|
|
#[test]
|
|
fn run_fails_at_build_step_in_mock_project() {
|
|
// The run command calls cmd_build_shortcut which runs cargo build --workspace.
|
|
// In a mock project without a real Cargo workspace, this fails at step 1.
|
|
// This tests that the pipeline starts and fails fast.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.assert()
|
|
.failure()
|
|
.stdout(predicate::str::contains("[1/5] Building..."));
|
|
}
|
|
|
|
#[test]
|
|
fn run_with_post_deploy_hook_shows_6_steps_in_output() {
|
|
// When a post_deploy hook is configured, the step counter shows /6.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
append_run_config(temp.path(), &["echo hello"]);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.assert()
|
|
.failure() // still fails at build
|
|
.stdout(predicate::str::contains("[1/6] Building..."));
|
|
}
|
|
|
|
#[test]
|
|
fn run_with_multiple_post_deploy_hooks_uses_array_form() {
|
|
// Multiple hooks are configured as a TOML inline array; pipeline still has 6 steps.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
append_run_config(temp.path(), &["echo one", "echo two"]);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.assert()
|
|
.failure() // still fails at build
|
|
.stdout(predicate::str::contains("[1/6] Building..."));
|
|
}
|
|
|
|
#[test]
|
|
fn run_no_post_deploy_flag_skips_configured_hooks() {
|
|
// --no-post-deploy must collapse total_steps back to 5 even when
|
|
// scaffold.toml configures hooks. The build still fails first, but the
|
|
// step counter in stdout proves the override was honored.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
append_run_config(temp.path(), &["echo configured"]);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.arg("--no-post-deploy")
|
|
.assert()
|
|
.failure()
|
|
.stdout(predicate::str::contains("[1/5] Building..."));
|
|
}
|
|
|
|
#[test]
|
|
fn run_post_deploy_flag_overrides_configured_hooks() {
|
|
// --post-deploy replaces config hooks. With one config hook ([1/6]) and
|
|
// two flag overrides, the resulting step count stays at /6 — proving
|
|
// that the flag took effect and config wasn't merged on top.
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
append_run_config(temp.path(), &["echo configured"]);
|
|
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.arg("--post-deploy")
|
|
.arg("echo override-a")
|
|
.arg("--post-deploy")
|
|
.arg("echo override-b")
|
|
.assert()
|
|
.failure()
|
|
.stdout(predicate::str::contains("[1/6] Building..."));
|
|
}
|
|
|
|
fn append_run_config(project_root: &Path, post_deploy: &[&str]) {
|
|
append_run_config_full(project_root, false, post_deploy);
|
|
}
|
|
|
|
fn append_run_config_full(project_root: &Path, reset: bool, post_deploy: &[&str]) {
|
|
let toml_path = project_root.join("scaffold.toml");
|
|
let mut content = fs::read_to_string(&toml_path).expect("read scaffold.toml");
|
|
let quoted: Vec<String> = post_deploy.iter().map(|c| format!("\"{c}\"")).collect();
|
|
content.push_str(&format!(
|
|
"\n[run]\nreset = {reset}\npost_deploy = [{}]\n",
|
|
quoted.join(", ")
|
|
));
|
|
fs::write(toml_path, content).expect("write scaffold.toml");
|
|
}
|
|
|
|
#[test]
|
|
fn run_rejects_both_reset_flags() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("run")
|
|
.arg("--reset")
|
|
.arg("--no-reset")
|
|
.assert()
|
|
.failure()
|
|
.stderr(predicate::str::contains("cannot be used with"));
|
|
}
|
|
|
|
#[test]
|
|
fn run_help_lists_reset_flags() {
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.arg("run")
|
|
.arg("--help")
|
|
.assert()
|
|
.success()
|
|
.stdout(predicate::str::contains("--reset").and(predicate::str::contains("--no-reset")));
|
|
}
|
|
|
|
#[test]
|
|
fn run_with_reset_flag_starts_pipeline() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
|
|
// --reset on the CLI: no banner (user typed the word) and pipeline
|
|
// enters step 1 before the build fails on this mock project.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.arg("--reset")
|
|
.assert()
|
|
.failure()
|
|
.stdout(predicate::str::contains("[1/5] Building..."))
|
|
.stderr(predicate::str::contains("scaffold.toml requested reset = true").not());
|
|
}
|
|
|
|
#[test]
|
|
fn run_with_reset_in_config_emits_destructive_intent_warning() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
append_run_config_full(temp.path(), true, &[]);
|
|
|
|
// reset = true in scaffold.toml without --reset on the CLI: the
|
|
// destructive-intent warning must fire on stderr before step 1, so a
|
|
// user who didn't realize `lgs run` would wipe their state finds out
|
|
// before the build runs.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.assert()
|
|
.failure()
|
|
.stdout(predicate::str::contains("[1/5] Building..."))
|
|
.stderr(predicate::str::contains(
|
|
"scaffold.toml requested reset = true",
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn run_no_reset_flag_overrides_config_reset_true() {
|
|
let temp = tempdir().expect("tempdir");
|
|
setup_wallet_project(temp.path(), Some("http://127.0.0.1:3040"));
|
|
append_run_config_full(temp.path(), true, &[]);
|
|
|
|
// --no-reset must beat the config field (CLI overrides config). When
|
|
// effective_reset is false, the destructive-intent banner must NOT
|
|
// fire — that's the observable signal proving the override took effect.
|
|
Command::new(assert_cmd::cargo::cargo_bin!("logos-scaffold"))
|
|
.current_dir(temp.path())
|
|
.arg("run")
|
|
.arg("--no-reset")
|
|
.assert()
|
|
.failure()
|
|
.stdout(predicate::str::contains("[1/5] Building..."))
|
|
.stderr(predicate::str::contains("scaffold.toml requested reset = true").not());
|
|
}
|