From 9bcab97b36cf13d42ae7156b7523fda378a178ca Mon Sep 17 00:00:00 2001 From: maciektr Date: Fri, 20 Sep 2024 08:16:38 -0700 Subject: [PATCH] Compile contract artifacts in test targets (#1585) - **Refactor starknet-contract compiler** - **Emit starknet contracts in test targets** --- .../compiler/compilers/starknet_contract.rs | 499 ------------------ .../starknet_contract/artifacts_writer.rs | 167 ++++++ .../compilers/starknet_contract/compiler.rs | 254 +++++++++ .../starknet_contract/contract_selector.rs | 73 +++ .../compilers/starknet_contract/mod.rs | 9 + .../starknet_contract/validations.rs | 119 +++++ scarb/src/compiler/compilers/test.rs | 55 +- scarb/src/core/manifest/target.rs | 13 +- scarb/src/core/manifest/toml_manifest.rs | 51 +- scarb/tests/build_targets.rs | 181 ++++++- 10 files changed, 900 insertions(+), 521 deletions(-) delete mode 100644 scarb/src/compiler/compilers/starknet_contract.rs create mode 100644 scarb/src/compiler/compilers/starknet_contract/artifacts_writer.rs create mode 100644 scarb/src/compiler/compilers/starknet_contract/compiler.rs create mode 100644 scarb/src/compiler/compilers/starknet_contract/contract_selector.rs create mode 100644 scarb/src/compiler/compilers/starknet_contract/mod.rs create mode 100644 scarb/src/compiler/compilers/starknet_contract/validations.rs diff --git a/scarb/src/compiler/compilers/starknet_contract.rs b/scarb/src/compiler/compilers/starknet_contract.rs deleted file mode 100644 index 977dffeb0..000000000 --- a/scarb/src/compiler/compilers/starknet_contract.rs +++ /dev/null @@ -1,499 +0,0 @@ -use anyhow::{bail, ensure, Context, Result}; -use cairo_lang_compiler::db::RootDatabase; -use cairo_lang_defs::ids::NamedLanguageElementId; -use cairo_lang_filesystem::db::{AsFilesGroupMut, FilesGroup}; -use cairo_lang_filesystem::flag::Flag; -use cairo_lang_filesystem::ids::{CrateId, CrateLongId, FlagId}; -use cairo_lang_semantic::db::SemanticGroup; -use cairo_lang_starknet::compile::compile_prepared_db; -use cairo_lang_starknet::contract::{find_contracts, ContractDeclaration}; -use cairo_lang_starknet_classes::allowed_libfuncs::{ - AllowedLibfuncsError, ListSelector, BUILTIN_EXPERIMENTAL_LIBFUNCS_LIST, -}; -use cairo_lang_starknet_classes::casm_contract_class::CasmContractClass; -use cairo_lang_starknet_classes::contract_class::ContractClass; -use cairo_lang_utils::{Upcast, UpcastMut}; -use indoc::{formatdoc, writedoc}; -use itertools::{izip, Itertools}; -use serde::{Deserialize, Serialize}; -use smol_str::SmolStr; -use std::collections::HashSet; -use std::fmt::Write; -use std::iter::zip; -use tracing::{debug, trace, trace_span}; - -use crate::compiler::helpers::{build_compiler_config, collect_main_crate_ids, write_json}; -use crate::compiler::{CairoCompilationUnit, CompilationUnitAttributes, Compiler}; -use crate::core::{PackageName, TargetKind, Utf8PathWorkspaceExt, Workspace}; -use crate::internal::serdex::RelativeUtf8PathBuf; -use scarb_stable_hash::short_hash; -use scarb_ui::Ui; - -const CAIRO_PATH_SEPARATOR: &str = "::"; -const GLOB_PATH_SELECTOR: &str = "*"; - -// TODO(#111): starknet-contract should be implemented as an extension. -pub struct StarknetContractCompiler; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -struct Props { - pub sierra: bool, - pub casm: bool, - pub casm_add_pythonic_hints: bool, - pub allowed_libfuncs: bool, - pub allowed_libfuncs_deny: bool, - pub allowed_libfuncs_list: Option, - pub build_external_contracts: Option>, -} - -impl Default for Props { - fn default() -> Self { - Self { - sierra: true, - casm: false, - casm_add_pythonic_hints: false, - allowed_libfuncs: true, - allowed_libfuncs_deny: false, - allowed_libfuncs_list: None, - build_external_contracts: None, - } - } -} - -// FIXME(#401): Make allowed-libfuncs-list.path relative to current Scarb.toml rather than PWD. -#[derive(Debug, Serialize, Deserialize)] -#[serde(untagged, rename_all = "kebab-case")] -pub enum SerdeListSelector { - Name { name: String }, - Path { path: RelativeUtf8PathBuf }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -struct ContractSelector(String); - -impl ContractSelector { - fn package(&self) -> PackageName { - let parts = self - .0 - .split_once(CAIRO_PATH_SEPARATOR) - .unwrap_or((self.0.as_str(), "")); - PackageName::new(parts.0) - } - - fn contract(&self) -> String { - let parts = self - .0 - .rsplit_once(CAIRO_PATH_SEPARATOR) - .unwrap_or((self.0.as_str(), "")); - parts.1.to_string() - } - - fn is_wildcard(&self) -> bool { - self.0.ends_with(GLOB_PATH_SELECTOR) - } - - fn partial_path(&self) -> String { - let parts = self - .0 - .split_once(GLOB_PATH_SELECTOR) - .unwrap_or((self.0.as_str(), "")); - parts.0.to_string() - } - - fn full_path(&self) -> String { - self.0.clone() - } -} - -struct ContractFileStemCalculator(HashSet); - -impl ContractFileStemCalculator { - fn new(contract_paths: Vec) -> Self { - let mut seen = HashSet::new(); - let contract_name_duplicates = contract_paths - .iter() - .map(|it| ContractSelector(it.clone()).contract()) - .filter(|contract_name| { - // insert returns false for duplicate values - !seen.insert(contract_name.clone()) - }) - .collect::>(); - Self(contract_name_duplicates) - } - - fn get_stem(&mut self, full_path: String) -> String { - let contract_selector = ContractSelector(full_path); - let contract_name = contract_selector.contract(); - - if self.0.contains(&contract_name) { - contract_selector - .full_path() - .replace(CAIRO_PATH_SEPARATOR, "_") - } else { - contract_name - } - } -} - -#[derive(Debug, Serialize)] -struct StarknetArtifacts { - version: usize, - contracts: Vec, -} - -impl Default for StarknetArtifacts { - fn default() -> Self { - Self { - version: 1, - contracts: Vec::new(), - } - } -} - -impl StarknetArtifacts { - fn finish(&mut self) { - assert!( - self.contracts.iter().map(|it| &it.id).all_unique(), - "Artifacts IDs must be unique." - ); - - self.contracts.sort_unstable_by_key(|it| it.id.clone()); - } -} - -#[derive(Debug, Serialize)] -struct ContractArtifacts { - id: String, - package_name: PackageName, - contract_name: String, - module_path: String, - artifacts: ContractArtifact, -} - -impl ContractArtifacts { - fn new( - package_name: &PackageName, - contract_name: &str, - contract_path: &str, - module_path: &str, - ) -> Self { - Self { - id: short_hash((&package_name, &contract_path)), - package_name: package_name.clone(), - contract_name: contract_name.to_owned(), - module_path: module_path.to_owned(), - artifacts: ContractArtifact::default(), - } - } -} - -#[derive(Debug, Default, Serialize)] -struct ContractArtifact { - sierra: Option, - casm: Option, -} - -const AUTO_WITHDRAW_GAS_FLAG: &str = "add_withdraw_gas"; - -impl Compiler for StarknetContractCompiler { - fn target_kind(&self) -> TargetKind { - TargetKind::STARKNET_CONTRACT.clone() - } - - fn compile( - &self, - unit: CairoCompilationUnit, - db: &mut RootDatabase, - ws: &Workspace<'_>, - ) -> Result<()> { - let props: Props = unit.main_component().target_props()?; - if !props.sierra && !props.casm { - ws.config().ui().warn( - "both Sierra and CASM Starknet contract targets have been disabled, \ - Scarb will not produce anything", - ); - } - - ensure_gas_enabled(db)?; - - if let Some(external_contracts) = props.build_external_contracts.clone() { - for path in external_contracts.iter() { - ensure!(path.0.matches(GLOB_PATH_SELECTOR).count() <= 1, - "external contract path `{}` has multiple global path selectors, only one '*' selector is allowed", - path.0); - } - } - - let target_dir = unit.target_dir(ws); - - let main_crate_ids = collect_main_crate_ids(&unit, db); - - let compiler_config = build_compiler_config(db, &unit, &main_crate_ids, ws); - - let contracts = find_project_contracts( - db.upcast_mut(), - ws.config().ui(), - main_crate_ids, - props.build_external_contracts.clone(), - )?; - - let contract_paths = contracts - .iter() - .map(|decl| decl.module_id().full_path(db.upcast_mut())) - .collect::>(); - trace!(contracts = ?contract_paths); - - let contracts = contracts.iter().collect::>(); - - let classes = { - let _ = trace_span!("compile_starknet").enter(); - compile_prepared_db(db, &contracts, compiler_config)? - }; - - check_allowed_libfuncs(&props, &contracts, &classes, db, &unit, ws)?; - - let casm_classes: Vec> = if props.casm { - let _ = trace_span!("compile_sierra").enter(); - zip(&contracts, &classes) - .map(|(decl, class)| -> Result<_> { - let contract_name = decl.submodule_id.name(db.upcast_mut()); - let casm_class = CasmContractClass::from_contract_class( - class.clone(), - props.casm_add_pythonic_hints, - usize::MAX, - ) - .with_context(|| { - format!("{contract_name}: failed to compile Sierra contract to CASM") - })?; - Ok(Some(casm_class)) - }) - .try_collect()? - } else { - classes.iter().map(|_| None).collect() - }; - - let mut artifacts = StarknetArtifacts::default(); - let mut file_stem_calculator = ContractFileStemCalculator::new(contract_paths); - - let target_name = &unit.main_component().target_name(); - for (decl, class, casm_class) in izip!(contracts, classes, casm_classes) { - let contract_name = decl.submodule_id.name(db.upcast_mut()); - let contract_path = decl.module_id().full_path(db.upcast_mut()); - - let contract_selector = ContractSelector(contract_path); - let package_name = contract_selector.package(); - let contract_stem = file_stem_calculator.get_stem(contract_selector.full_path()); - - let file_stem = format!("{target_name}_{contract_stem}"); - - let mut artifact = ContractArtifacts::new( - &package_name, - &contract_name, - contract_selector.full_path().as_str(), - &decl.module_id().full_path(db.upcast_mut()), - ); - - if props.sierra { - let file_name = format!("{file_stem}.contract_class.json"); - write_json(&file_name, "output file", &target_dir, ws, &class)?; - artifact.artifacts.sierra = Some(file_name); - } - - // if props.casm - if let Some(casm_class) = casm_class { - let file_name = format!("{file_stem}.compiled_contract_class.json"); - write_json(&file_name, "output file", &target_dir, ws, &casm_class)?; - artifact.artifacts.casm = Some(file_name); - } - - artifacts.contracts.push(artifact); - } - - artifacts.finish(); - - write_json( - &format!("{}.starknet_artifacts.json", target_name), - "starknet artifacts file", - &target_dir, - ws, - &artifacts, - )?; - - Ok(()) - } -} - -fn ensure_gas_enabled(db: &mut RootDatabase) -> Result<()> { - let flag = FlagId::new(db.as_files_group_mut(), AUTO_WITHDRAW_GAS_FLAG); - let flag = db.get_flag(flag); - ensure!( - flag.map(|f| matches!(*f, Flag::AddWithdrawGas(true))) - .unwrap_or(false), - "the target starknet contract compilation requires gas to be enabled" - ); - Ok(()) -} - -fn find_project_contracts( - mut db: &dyn SemanticGroup, - ui: Ui, - main_crate_ids: Vec, - external_contracts: Option>, -) -> Result> { - let internal_contracts = { - let _ = trace_span!("find_internal_contracts").enter(); - find_contracts(db, &main_crate_ids) - }; - - let external_contracts: Vec = - if let Some(external_contracts) = external_contracts { - let _ = trace_span!("find_external_contracts").enter(); - debug!("external contracts selectors: {:?}", external_contracts); - - let crate_ids = external_contracts - .iter() - .map(|selector| selector.package().into()) - .unique() - .map(|package_name: SmolStr| { - db.upcast_mut() - .intern_crate(CrateLongId::Real(package_name)) - }) - .collect::>(); - let contracts = find_contracts(db, crate_ids.as_ref()); - let filtered_contracts: Vec = contracts - .into_iter() - .filter(|decl| { - let contract_path = decl.module_id().full_path(db.upcast()); - external_contracts - .iter() - .any(|selector| contract_matches(selector, contract_path.as_str())) - }) - .collect(); - - let never_matched = external_contracts - .iter() - .filter(|selector| { - !filtered_contracts.iter().any(|decl| { - let contract_path = decl.module_id().full_path(db.upcast()); - contract_matches(selector, contract_path.as_str()) - }) - }) - .collect_vec(); - if !never_matched.is_empty() { - let never_matched = never_matched - .iter() - .map(|selector| selector.full_path()) - .collect_vec() - .join("`, `"); - ui.warn(format!( - "external contracts not found for selectors: `{never_matched}`" - )); - } - - filtered_contracts - } else { - debug!("no external contracts selected"); - Vec::new() - }; - - Ok(internal_contracts - .into_iter() - .chain(external_contracts) - .collect()) -} - -fn contract_matches(selector: &ContractSelector, contract_path: &str) -> bool { - if selector.is_wildcard() { - contract_path.starts_with(&selector.partial_path()) - } else { - contract_path == selector.full_path() - } -} - -fn check_allowed_libfuncs( - props: &Props, - contracts: &[&ContractDeclaration], - classes: &[ContractClass], - db: &RootDatabase, - unit: &CairoCompilationUnit, - ws: &Workspace<'_>, -) -> Result<()> { - if !props.allowed_libfuncs { - debug!("allowed libfuncs checking disabled by target props"); - return Ok(()); - } - - let list_selector = match &props.allowed_libfuncs_list { - Some(SerdeListSelector::Name { name }) => ListSelector::ListName(name.clone()), - Some(SerdeListSelector::Path { path }) => { - let path = path.relative_to_file(unit.main_component().package.manifest_path())?; - ListSelector::ListFile(path.into_string()) - } - None => Default::default(), - }; - - let mut found_disallowed = false; - for (decl, class) in zip(contracts, classes) { - match class.validate_version_compatible(list_selector.clone()) { - Ok(()) => {} - - Err(AllowedLibfuncsError::UnsupportedLibfunc { - invalid_libfunc, - allowed_libfuncs_list_name, - }) => { - found_disallowed = true; - - let contract_name = decl.submodule_id.name(db.upcast()); - let mut diagnostic = formatdoc! {r#" - libfunc `{invalid_libfunc}` is not allowed in the libfuncs list `{allowed_libfuncs_list_name}` - --> contract: {contract_name} - "#}; - - // If user did not explicitly specify the allowlist, show a help message - // instructing how to do this. Otherwise, we know that user knows what they - // do, so we do not clutter compiler output. - if list_selector == Default::default() { - let experimental = BUILTIN_EXPERIMENTAL_LIBFUNCS_LIST; - - let scarb_toml = unit - .main_component() - .package - .manifest_path() - .workspace_relative(ws); - - let _ = writedoc!( - &mut diagnostic, - r#" - help: try compiling with the `{experimental}` list - --> {scarb_toml} - [[target.starknet-contract]] - allowed-libfuncs-list.name = "{experimental}" - "# - ); - } - - if props.allowed_libfuncs_deny { - ws.config().ui().error(diagnostic); - } else { - ws.config().ui().warn(diagnostic); - } - } - - Err(e) => { - return Err(e).with_context(|| { - format!( - "failed to check allowed libfuncs for contract: {contract_name}", - contract_name = decl.submodule_id.name(db.upcast()) - ) - }) - } - } - } - - if found_disallowed && props.allowed_libfuncs_deny { - bail!("aborting compilation, because contracts use disallowed Sierra libfuncs"); - } - - Ok(()) -} diff --git a/scarb/src/compiler/compilers/starknet_contract/artifacts_writer.rs b/scarb/src/compiler/compilers/starknet_contract/artifacts_writer.rs new file mode 100644 index 000000000..7ee0711c8 --- /dev/null +++ b/scarb/src/compiler/compilers/starknet_contract/artifacts_writer.rs @@ -0,0 +1,167 @@ +use crate::compiler::compilers::starknet_contract::{ContractFileStemCalculator, ContractSelector}; +use crate::compiler::compilers::Props; +use crate::compiler::helpers::write_json; +use crate::core::{PackageName, Workspace}; +use crate::flock::Filesystem; +use cairo_lang_compiler::db::RootDatabase; +use cairo_lang_defs::ids::NamedLanguageElementId; +use cairo_lang_starknet::contract::ContractDeclaration; +use cairo_lang_starknet_classes::casm_contract_class::CasmContractClass; +use cairo_lang_starknet_classes::contract_class::ContractClass; +use cairo_lang_utils::UpcastMut; +use itertools::{izip, Itertools}; +use scarb_stable_hash::short_hash; +use serde::Serialize; +use smol_str::SmolStr; + +#[derive(Debug, Serialize)] +struct StarknetArtifacts { + version: usize, + contracts: Vec, +} + +impl Default for StarknetArtifacts { + fn default() -> Self { + Self { + version: 1, + contracts: Vec::new(), + } + } +} + +impl StarknetArtifacts { + fn finish(&mut self) { + assert!( + self.contracts.iter().map(|it| &it.id).all_unique(), + "Artifacts IDs must be unique." + ); + + self.contracts.sort_unstable_by_key(|it| it.id.clone()); + } +} + +#[derive(Debug, Serialize)] +struct ContractArtifacts { + id: String, + package_name: PackageName, + contract_name: String, + module_path: String, + artifacts: ContractArtifact, +} + +impl ContractArtifacts { + fn new( + package_name: PackageName, + contract_name: &str, + contract_path: &str, + module_path: &str, + ) -> Self { + Self { + id: short_hash((&package_name, &contract_path)), + package_name, + contract_name: contract_name.to_owned(), + module_path: module_path.to_owned(), + artifacts: ContractArtifact::default(), + } + } +} + +#[derive(Debug, Default, Serialize)] +struct ContractArtifact { + sierra: Option, + casm: Option, +} + +pub struct ArtifactsWriter { + sierra: bool, + casm: bool, + target_dir: Filesystem, + target_name: SmolStr, + extension_prefix: Option, +} + +impl ArtifactsWriter { + pub fn new(target_name: SmolStr, target_dir: Filesystem, props: Props) -> Self { + Self { + sierra: props.sierra, + casm: props.casm, + target_dir, + target_name, + extension_prefix: None, + } + } + + pub fn with_extension_prefix(self, prefix: String) -> Self { + Self { + extension_prefix: Some(prefix), + ..self + } + } + + pub fn write( + self, + contract_paths: Vec, + contracts: &Vec, + classes: &[ContractClass], + casm_classes: &[Option], + db: &mut RootDatabase, + ws: &Workspace<'_>, + ) -> anyhow::Result<()> { + let mut artifacts = StarknetArtifacts::default(); + let mut file_stem_calculator = ContractFileStemCalculator::new(contract_paths); + let extension_prefix = self + .extension_prefix + .map(|ext| format!(".{ext}")) + .unwrap_or_default(); + + for (declaration, class, casm_class) in izip!(contracts, classes, casm_classes) { + let contract_name = declaration.submodule_id.name(db.upcast_mut()); + let contract_path = declaration.module_id().full_path(db.upcast_mut()); + + let contract_selector = ContractSelector(contract_path); + let package_name = contract_selector.package(); + let contract_stem = file_stem_calculator.get_stem(contract_selector.full_path()); + + let file_stem = format!("{}_{contract_stem}", self.target_name); + + let mut artifact = ContractArtifacts::new( + package_name, + &contract_name, + contract_selector.full_path().as_str(), + &declaration.module_id().full_path(db.upcast_mut()), + ); + + if self.sierra { + let file_name = format!("{file_stem}{extension_prefix}.contract_class.json"); + write_json(&file_name, "output file", &self.target_dir, ws, class)?; + artifact.artifacts.sierra = Some(file_name); + } + + if self.casm { + if let Some(casm_class) = casm_class { + let file_name = + format!("{file_stem}{extension_prefix}.compiled_contract_class.json"); + write_json(&file_name, "output file", &self.target_dir, ws, casm_class)?; + artifact.artifacts.casm = Some(file_name); + } + } + + artifacts.contracts.push(artifact); + } + + artifacts.finish(); + + write_json( + &format!( + "{}{extension_prefix}.starknet_artifacts.json", + self.target_name + ), + "starknet artifacts file", + &self.target_dir, + ws, + &artifacts, + )?; + + Ok(()) + } +} diff --git a/scarb/src/compiler/compilers/starknet_contract/compiler.rs b/scarb/src/compiler/compilers/starknet_contract/compiler.rs new file mode 100644 index 000000000..915085beb --- /dev/null +++ b/scarb/src/compiler/compilers/starknet_contract/compiler.rs @@ -0,0 +1,254 @@ +use anyhow::{ensure, Context, Result}; +use cairo_lang_compiler::db::RootDatabase; +use cairo_lang_compiler::CompilerConfig; +use cairo_lang_defs::ids::NamedLanguageElementId; +use cairo_lang_filesystem::ids::{CrateId, CrateLongId}; +use cairo_lang_semantic::db::SemanticGroup; +use cairo_lang_starknet::compile::compile_prepared_db; +use cairo_lang_starknet::contract::{find_contracts, ContractDeclaration}; +use cairo_lang_starknet_classes::casm_contract_class::CasmContractClass; +use cairo_lang_starknet_classes::contract_class::ContractClass; +use cairo_lang_utils::UpcastMut; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; +use std::iter::zip; +use tracing::{debug, trace, trace_span}; + +use super::contract_selector::ContractSelector; +use crate::compiler::compilers::starknet_contract::contract_selector::GLOB_PATH_SELECTOR; +use crate::compiler::compilers::starknet_contract::validations::check_allowed_libfuncs; +use crate::compiler::compilers::{ensure_gas_enabled, ArtifactsWriter}; +use crate::compiler::helpers::{build_compiler_config, collect_main_crate_ids}; +use crate::compiler::{CairoCompilationUnit, CompilationUnitAttributes, Compiler}; +use crate::core::{TargetKind, Workspace}; +use crate::internal::serdex::RelativeUtf8PathBuf; +use scarb_ui::Ui; + +// TODO(#111): starknet-contract should be implemented as an extension. +pub struct StarknetContractCompiler; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct Props { + pub sierra: bool, + pub casm: bool, + pub casm_add_pythonic_hints: bool, + pub allowed_libfuncs: bool, + pub allowed_libfuncs_deny: bool, + pub allowed_libfuncs_list: Option, + pub build_external_contracts: Option>, +} + +impl Default for Props { + fn default() -> Self { + Self { + sierra: true, + casm: false, + casm_add_pythonic_hints: false, + allowed_libfuncs: true, + allowed_libfuncs_deny: false, + allowed_libfuncs_list: None, + build_external_contracts: None, + } + } +} + +// FIXME(#401): Make allowed-libfuncs-list.path relative to current Scarb.toml rather than PWD. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged, rename_all = "kebab-case")] +pub enum SerdeListSelector { + Name { name: String }, + Path { path: RelativeUtf8PathBuf }, +} + +impl Compiler for StarknetContractCompiler { + fn target_kind(&self) -> TargetKind { + TargetKind::STARKNET_CONTRACT.clone() + } + + fn compile( + &self, + unit: CairoCompilationUnit, + db: &mut RootDatabase, + ws: &Workspace<'_>, + ) -> Result<()> { + let props: Props = unit.main_component().target_props()?; + if !props.sierra && !props.casm { + ws.config().ui().warn( + "both Sierra and CASM Starknet contract targets have been disabled, \ + Scarb will not produce anything", + ); + } + + ensure_gas_enabled(db)?; + + if let Some(external_contracts) = props.build_external_contracts.clone() { + for path in external_contracts.iter() { + ensure!(path.0.matches(GLOB_PATH_SELECTOR).count() <= 1, + "external contract path `{}` has multiple global path selectors, only one '*' selector is allowed", + path.0); + } + } + + let target_dir = unit.target_dir(ws); + + let main_crate_ids = collect_main_crate_ids(&unit, db); + + let compiler_config = build_compiler_config(db, &unit, &main_crate_ids, ws); + + let CompiledContracts { + contract_paths, + contracts, + classes, + } = get_compiled_contracts( + main_crate_ids, + props.build_external_contracts.clone(), + compiler_config, + db, + ws, + )?; + + check_allowed_libfuncs(&props, &contracts, &classes, db, &unit, ws)?; + + let casm_classes: Vec> = if props.casm { + let _ = trace_span!("compile_sierra").enter(); + zip(&contracts, &classes) + .map(|(decl, class)| -> Result<_> { + let contract_name = decl.submodule_id.name(db.upcast_mut()); + let casm_class = CasmContractClass::from_contract_class( + class.clone(), + props.casm_add_pythonic_hints, + usize::MAX, + ) + .with_context(|| { + format!("{contract_name}: failed to compile Sierra contract to CASM") + })?; + Ok(Some(casm_class)) + }) + .try_collect()? + } else { + classes.iter().map(|_| None).collect() + }; + + let target_name = &unit.main_component().target_name(); + + let writer = ArtifactsWriter::new(target_name.clone(), target_dir, props); + writer.write(contract_paths, &contracts, &classes, &casm_classes, db, ws)?; + + Ok(()) + } +} + +pub struct CompiledContracts { + pub contract_paths: Vec, + pub contracts: Vec, + pub classes: Vec, +} + +pub fn get_compiled_contracts( + main_crate_ids: Vec, + build_external_contracts: Option>, + compiler_config: CompilerConfig<'_>, + db: &mut RootDatabase, + ws: &Workspace<'_>, +) -> Result { + let contracts = find_project_contracts( + db.upcast_mut(), + ws.config().ui(), + main_crate_ids, + build_external_contracts, + )?; + + let contract_paths = contracts + .iter() + .map(|decl| decl.module_id().full_path(db.upcast_mut())) + .collect::>(); + trace!(contracts = ?contract_paths); + + let classes = { + let _ = trace_span!("compile_starknet").enter(); + compile_prepared_db(db, &contracts.iter().collect::>(), compiler_config)? + }; + Ok(CompiledContracts { + contract_paths, + contracts, + classes, + }) +} + +fn find_project_contracts( + mut db: &dyn SemanticGroup, + ui: Ui, + main_crate_ids: Vec, + external_contracts: Option>, +) -> Result> { + let internal_contracts = { + let _ = trace_span!("find_internal_contracts").enter(); + find_contracts(db, &main_crate_ids) + }; + + let external_contracts: Vec = + if let Some(external_contracts) = external_contracts { + let _ = trace_span!("find_external_contracts").enter(); + debug!("external contracts selectors: {:?}", external_contracts); + + let crate_ids = external_contracts + .iter() + .map(|selector| selector.package().into()) + .unique() + .map(|package_name: SmolStr| { + db.upcast_mut() + .intern_crate(CrateLongId::Real(package_name)) + }) + .collect::>(); + let contracts = find_contracts(db, crate_ids.as_ref()); + let filtered_contracts: Vec = contracts + .into_iter() + .filter(|decl| { + let contract_path = decl.module_id().full_path(db.upcast()); + external_contracts + .iter() + .any(|selector| contract_matches(selector, contract_path.as_str())) + }) + .collect(); + + let never_matched = external_contracts + .iter() + .filter(|selector| { + !filtered_contracts.iter().any(|decl| { + let contract_path = decl.module_id().full_path(db.upcast()); + contract_matches(selector, contract_path.as_str()) + }) + }) + .collect_vec(); + if !never_matched.is_empty() { + let never_matched = never_matched + .iter() + .map(|selector| selector.full_path()) + .collect_vec() + .join("`, `"); + ui.warn(format!( + "external contracts not found for selectors: `{never_matched}`" + )); + } + + filtered_contracts + } else { + debug!("no external contracts selected"); + Vec::new() + }; + + Ok(internal_contracts + .into_iter() + .chain(external_contracts) + .collect()) +} + +fn contract_matches(selector: &ContractSelector, contract_path: &str) -> bool { + if selector.is_wildcard() { + contract_path.starts_with(&selector.partial_path()) + } else { + contract_path == selector.full_path() + } +} diff --git a/scarb/src/compiler/compilers/starknet_contract/contract_selector.rs b/scarb/src/compiler/compilers/starknet_contract/contract_selector.rs new file mode 100644 index 000000000..5e576b32e --- /dev/null +++ b/scarb/src/compiler/compilers/starknet_contract/contract_selector.rs @@ -0,0 +1,73 @@ +use crate::core::PackageName; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +pub const CAIRO_PATH_SEPARATOR: &str = "::"; +pub const GLOB_PATH_SELECTOR: &str = "*"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContractSelector(pub String); + +impl ContractSelector { + pub fn package(&self) -> PackageName { + let parts = self + .0 + .split_once(CAIRO_PATH_SEPARATOR) + .unwrap_or((self.0.as_str(), "")); + PackageName::new(parts.0) + } + + pub fn contract(&self) -> String { + let parts = self + .0 + .rsplit_once(CAIRO_PATH_SEPARATOR) + .unwrap_or((self.0.as_str(), "")); + parts.1.to_string() + } + + pub fn is_wildcard(&self) -> bool { + self.0.ends_with(GLOB_PATH_SELECTOR) + } + + pub fn partial_path(&self) -> String { + let parts = self + .0 + .split_once(GLOB_PATH_SELECTOR) + .unwrap_or((self.0.as_str(), "")); + parts.0.to_string() + } + + pub fn full_path(&self) -> String { + self.0.clone() + } +} + +pub struct ContractFileStemCalculator(HashSet); + +impl ContractFileStemCalculator { + pub fn new(contract_paths: Vec) -> Self { + let mut seen = HashSet::new(); + let contract_name_duplicates = contract_paths + .iter() + .map(|it| ContractSelector(it.clone()).contract()) + .filter(|contract_name| { + // insert returns false for duplicate values + !seen.insert(contract_name.clone()) + }) + .collect::>(); + Self(contract_name_duplicates) + } + + pub fn get_stem(&mut self, full_path: String) -> String { + let contract_selector = ContractSelector(full_path); + let contract_name = contract_selector.contract(); + + if self.0.contains(&contract_name) { + contract_selector + .full_path() + .replace(CAIRO_PATH_SEPARATOR, "_") + } else { + contract_name + } + } +} diff --git a/scarb/src/compiler/compilers/starknet_contract/mod.rs b/scarb/src/compiler/compilers/starknet_contract/mod.rs new file mode 100644 index 000000000..2ebe851b4 --- /dev/null +++ b/scarb/src/compiler/compilers/starknet_contract/mod.rs @@ -0,0 +1,9 @@ +pub use artifacts_writer::ArtifactsWriter; +pub use compiler::*; +pub use contract_selector::{ContractFileStemCalculator, ContractSelector}; +pub use validations::ensure_gas_enabled; + +mod artifacts_writer; +mod compiler; +mod contract_selector; +mod validations; diff --git a/scarb/src/compiler/compilers/starknet_contract/validations.rs b/scarb/src/compiler/compilers/starknet_contract/validations.rs new file mode 100644 index 000000000..7daa93d03 --- /dev/null +++ b/scarb/src/compiler/compilers/starknet_contract/validations.rs @@ -0,0 +1,119 @@ +use crate::compiler::compilers::{Props, SerdeListSelector}; +use crate::compiler::{CairoCompilationUnit, CompilationUnitAttributes}; +use crate::core::{Utf8PathWorkspaceExt, Workspace}; +use anyhow::{bail, ensure, Context}; +use cairo_lang_compiler::db::RootDatabase; +use cairo_lang_defs::ids::NamedLanguageElementId; +use cairo_lang_filesystem::db::{AsFilesGroupMut, FilesGroup}; +use cairo_lang_filesystem::flag::Flag; +use cairo_lang_filesystem::ids::FlagId; +use cairo_lang_starknet::contract::ContractDeclaration; +use cairo_lang_starknet_classes::allowed_libfuncs::{ + AllowedLibfuncsError, ListSelector, BUILTIN_EXPERIMENTAL_LIBFUNCS_LIST, +}; +use cairo_lang_starknet_classes::contract_class::ContractClass; +use cairo_lang_utils::Upcast; +use indoc::{formatdoc, writedoc}; +use std::fmt::Write; +use std::iter::zip; +use tracing::debug; + +const AUTO_WITHDRAW_GAS_FLAG: &str = "add_withdraw_gas"; + +pub fn ensure_gas_enabled(db: &mut RootDatabase) -> anyhow::Result<()> { + let flag = FlagId::new(db.as_files_group_mut(), AUTO_WITHDRAW_GAS_FLAG); + let flag = db.get_flag(flag); + ensure!( + flag.map(|f| matches!(*f, Flag::AddWithdrawGas(true))) + .unwrap_or(false), + "the target starknet contract compilation requires gas to be enabled" + ); + Ok(()) +} + +pub fn check_allowed_libfuncs( + props: &Props, + contracts: &[ContractDeclaration], + classes: &[ContractClass], + db: &RootDatabase, + unit: &CairoCompilationUnit, + ws: &Workspace<'_>, +) -> anyhow::Result<()> { + if !props.allowed_libfuncs { + debug!("allowed libfuncs checking disabled by target props"); + return Ok(()); + } + + let list_selector = match &props.allowed_libfuncs_list { + Some(SerdeListSelector::Name { name }) => ListSelector::ListName(name.clone()), + Some(SerdeListSelector::Path { path }) => { + let path = path.relative_to_file(unit.main_component().package.manifest_path())?; + ListSelector::ListFile(path.into_string()) + } + None => Default::default(), + }; + + let mut found_disallowed = false; + for (decl, class) in zip(contracts, classes) { + match class.validate_version_compatible(list_selector.clone()) { + Ok(()) => {} + + Err(AllowedLibfuncsError::UnsupportedLibfunc { + invalid_libfunc, + allowed_libfuncs_list_name, + }) => { + found_disallowed = true; + + let contract_name = decl.submodule_id.name(db.upcast()); + let mut diagnostic = formatdoc! {r#" + libfunc `{invalid_libfunc}` is not allowed in the libfuncs list `{allowed_libfuncs_list_name}` + --> contract: {contract_name} + "#}; + + // If user did not explicitly specify the allowlist, show a help message + // instructing how to do this. Otherwise, we know that user knows what they + // do, so we do not clutter compiler output. + if list_selector == Default::default() { + let experimental = BUILTIN_EXPERIMENTAL_LIBFUNCS_LIST; + + let scarb_toml = unit + .main_component() + .package + .manifest_path() + .workspace_relative(ws); + + let _ = writedoc!( + &mut diagnostic, + r#" + help: try compiling with the `{experimental}` list + --> {scarb_toml} + [[target.starknet-contract]] + allowed-libfuncs-list.name = "{experimental}" + "# + ); + } + + if props.allowed_libfuncs_deny { + ws.config().ui().error(diagnostic); + } else { + ws.config().ui().warn(diagnostic); + } + } + + Err(e) => { + return Err(e).with_context(|| { + format!( + "failed to check allowed libfuncs for contract: {contract_name}", + contract_name = decl.submodule_id.name(db.upcast()) + ) + }) + } + } + } + + if found_disallowed && props.allowed_libfuncs_deny { + bail!("aborting compilation, because contracts use disallowed Sierra libfuncs"); + } + + Ok(()) +} diff --git a/scarb/src/compiler/compilers/test.rs b/scarb/src/compiler/compilers/test.rs index 60ec464f8..22b22a181 100644 --- a/scarb/src/compiler/compilers/test.rs +++ b/scarb/src/compiler/compilers/test.rs @@ -1,14 +1,23 @@ use anyhow::Result; use cairo_lang_compiler::db::RootDatabase; +use cairo_lang_filesystem::ids::CrateId; use cairo_lang_sierra::program::VersionedProgram; +use cairo_lang_starknet_classes::casm_contract_class::CasmContractClass; use cairo_lang_test_plugin::{compile_test_prepared_db, TestsCompilationConfig}; +use itertools::Itertools; use tracing::trace_span; +use crate::compiler::compilers::starknet_contract::Props as StarknetContractProps; +use crate::compiler::compilers::{ + ensure_gas_enabled, get_compiled_contracts, ArtifactsWriter, CompiledContracts, + ContractSelector, +}; use crate::compiler::helpers::{ build_compiler_config, collect_all_crate_ids, collect_main_crate_ids, write_json, }; use crate::compiler::{CairoCompilationUnit, CompilationUnitAttributes, Compiler}; -use crate::core::{PackageName, SourceId, TargetKind, Workspace}; +use crate::core::{PackageName, SourceId, TargetKind, TestTargetProps, Workspace}; +use crate::flock::Filesystem; pub struct TestCompiler; @@ -50,7 +59,7 @@ impl Compiler for TestCompiler { db, config, all_crate_ids, - test_crate_ids, + test_crate_ids.clone(), diagnostics_reporter, )? }; @@ -72,6 +81,48 @@ impl Compiler for TestCompiler { )?; } + if starknet { + // Note: this will only search for contracts in the main CU component and + // `build-external-contracts`. It will not collect contracts from all dependencies. + compile_contracts(test_crate_ids, target_dir, unit, db, ws)?; + } + Ok(()) } } + +fn compile_contracts( + main_crate_ids: Vec, + target_dir: Filesystem, + unit: CairoCompilationUnit, + db: &mut RootDatabase, + ws: &Workspace<'_>, +) -> Result<()> { + ensure_gas_enabled(db)?; + let target_name = unit.main_component().target_name(); + let test_props: TestTargetProps = unit.main_component().target_props()?; + let external_contracts = test_props + .build_external_contracts + .map(|contracts| contracts.into_iter().map(ContractSelector).collect_vec()); + let props = StarknetContractProps { + build_external_contracts: external_contracts, + ..StarknetContractProps::default() + }; + let compiler_config = build_compiler_config(db, &unit, &main_crate_ids, ws); + let CompiledContracts { + contract_paths, + contracts, + classes, + } = get_compiled_contracts( + main_crate_ids, + props.build_external_contracts.clone(), + compiler_config, + db, + ws, + )?; + let writer = ArtifactsWriter::new(target_name.clone(), target_dir, props) + .with_extension_prefix("test".to_string()); + let casm_classes: Vec> = classes.iter().map(|_| None).collect(); + writer.write(contract_paths, &contracts, &classes, &casm_classes, db, ws)?; + Ok(()) +} diff --git a/scarb/src/core/manifest/target.rs b/scarb/src/core/manifest/target.rs index d09f3a18c..941f48deb 100644 --- a/scarb/src/core/manifest/target.rs +++ b/scarb/src/core/manifest/target.rs @@ -114,11 +114,22 @@ impl Hash for TargetInner { #[serde(rename_all = "kebab-case")] pub struct TestTargetProps { pub test_type: TestTargetType, + pub build_external_contracts: Option>, } impl TestTargetProps { pub fn new(test_type: TestTargetType) -> Self { - Self { test_type } + Self { + test_type, + build_external_contracts: Default::default(), + } + } + + pub fn with_build_external_contracts(self, external: Vec) -> Self { + Self { + build_external_contracts: Some(external), + ..self + } } } diff --git a/scarb/src/core/manifest/toml_manifest.rs b/scarb/src/core/manifest/toml_manifest.rs index 8fd3f9601..ccb17b2f5 100644 --- a/scarb/src/core/manifest/toml_manifest.rs +++ b/scarb/src/core/manifest/toml_manifest.rs @@ -640,18 +640,18 @@ impl TomlManifest { // Skip autodetect for cairo plugins. let auto_detect = !targets.iter().any(Target::is_cairo_plugin); - targets.extend(self.collect_test_targets(package_name.clone(), root, auto_detect)?); + self.collect_test_targets(&mut targets, package_name.clone(), root, auto_detect)?; Ok(targets) } fn collect_test_targets( &self, + targets: &mut Vec, package_name: SmolStr, root: &Utf8Path, auto_detect: bool, - ) -> Result> { - let mut targets = Vec::new(); + ) -> Result<()> { if let Some(test) = self.test.as_ref() { // Read test targets from manifest file. for test_toml in test { @@ -665,13 +665,31 @@ impl TomlManifest { } } else if auto_detect { // Auto-detect test target. + let external_contracts = targets + .iter() + .filter(|target| target.kind == TargetKind::STARKNET_CONTRACT) + .filter_map(|target| target.params.get("build-external-contracts")) + .filter_map(|value| value.as_array()) + .flatten() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .sorted() + .dedup() + .collect_vec(); let source_path = self.lib.as_ref().and_then(|l| l.source_path.clone()); let target_name: SmolStr = format!("{package_name}_unittest").into(); let target_config = TomlTarget:: { name: Some(target_name), source_path, - params: TestTargetProps::default().try_into()?, + params: TestTargetProps::default() + .with_build_external_contracts(external_contracts.clone()) + .try_into()?, }; + let external_contracts = external_contracts + .into_iter() + .chain(vec![format!("{package_name}::*")]) + .sorted() + .dedup() + .collect_vec(); targets.extend(Self::collect_target::( TargetKind::TEST, Some(&target_config), @@ -681,16 +699,23 @@ impl TomlManifest { )?); // Auto-detect test targets from `tests` directory. let tests_path = root.join(DEFAULT_TESTS_PATH); + let integration_target_config = |target_name, source_path| { + let result: Result> = + Ok(TomlTarget:: { + name: Some(target_name), + source_path: Some(source_path), + params: TestTargetProps::new(TestTargetType::Integration) + .with_build_external_contracts(external_contracts.clone()) + .try_into()?, + }); + result + }; if tests_path.join(DEFAULT_MODULE_MAIN_FILE).exists() { // Tests directory contains `lib.cairo` file. // Treat whole tests directory as single module. let source_path = tests_path.join(DEFAULT_MODULE_MAIN_FILE); let target_name: SmolStr = format!("{package_name}_{DEFAULT_TESTS_PATH}").into(); - let target_config = TomlTarget:: { - name: Some(target_name), - source_path: Some(source_path), - params: TestTargetProps::new(TestTargetType::Integration).try_into()?, - }; + let target_config = integration_target_config(target_name, source_path)?; targets.extend(Self::collect_target::( TargetKind::TEST, Some(&target_config), @@ -720,11 +745,7 @@ impl TomlManifest { } let file_stem = source_path.file_stem().unwrap().to_string(); let target_name: SmolStr = format!("{package_name}_{file_stem}").into(); - let target_config = TomlTarget:: { - name: Some(target_name), - source_path: Some(source_path), - params: TestTargetProps::new(TestTargetType::Integration).try_into()?, - }; + let target_config = integration_target_config(target_name, source_path)?; targets.extend(Self::collect_target( TargetKind::TEST, Some(&target_config), @@ -736,7 +757,7 @@ impl TomlManifest { } } }; - Ok(targets) + Ok(()) } fn collect_target( diff --git a/scarb/tests/build_targets.rs b/scarb/tests/build_targets.rs index a067dd1a2..13ce5cace 100644 --- a/scarb/tests/build_targets.rs +++ b/scarb/tests/build_targets.rs @@ -1,16 +1,17 @@ use assert_fs::prelude::*; use assert_fs::TempDir; use cairo_lang_sierra::program::VersionedProgram; -use indoc::indoc; +use cairo_lang_starknet_classes::contract_class::ContractClass; +use indoc::{formatdoc, indoc}; use itertools::Itertools; use predicates::prelude::*; use scarb_metadata::Metadata; -use std::path::PathBuf; - use scarb_test_support::command::{CommandExt, Scarb}; +use scarb_test_support::contracts::{BALANCE_CONTRACT, FORTY_TWO_CONTRACT, HELLO_CONTRACT}; use scarb_test_support::fsx; use scarb_test_support::fsx::ChildPathEx; -use scarb_test_support::project_builder::ProjectBuilder; +use scarb_test_support::project_builder::{Dep, DepBuilder, ProjectBuilder}; +use std::path::PathBuf; #[test] fn compile_with_duplicate_targets_1() { @@ -724,3 +725,175 @@ fn can_use_test_and_target_names() { .assert() .success(); } + +#[test] +fn test_target_builds_contracts() { + let t = TempDir::new().unwrap(); + ProjectBuilder::start() + .name("hello") + .version("0.1.0") + .manifest_extra(indoc! {r#" + [lib] + sierra = true + + [[target.starknet-contract]] + "#}) + .dep_starknet() + .dep_cairo_test() + .lib_cairo(indoc! {r#" + pub mod balance; + pub mod forty_two; + "#}) + .src("src/balance.cairo", BALANCE_CONTRACT) + .src("src/forty_two.cairo", FORTY_TWO_CONTRACT) + .build(&t); + + t.child("tests/contract_test.cairo") + .write_str( + formatdoc! {r#" + #[cfg(test)] + mod tests {{ + + {HELLO_CONTRACT} + + use array::ArrayTrait; + use core::result::ResultTrait; + use core::traits::Into; + use option::OptionTrait; + use starknet::syscalls::deploy_syscall; + use traits::TryInto; + + use hello::balance::{{Balance, IBalance, IBalanceDispatcher, IBalanceDispatcherTrait}}; + + #[test] + fn test_flow() {{ + let calldata = array![100]; + let (address0, _) = deploy_syscall( + Balance::TEST_CLASS_HASH.try_into().unwrap(), 0, calldata.span(), false + ) + .unwrap(); + let mut contract0 = IBalanceDispatcher {{ contract_address: address0 }}; + + let calldata = array![200]; + let (address1, _) = deploy_syscall( + Balance::TEST_CLASS_HASH.try_into().unwrap(), 0, calldata.span(), false + ) + .unwrap(); + let mut contract1 = IBalanceDispatcher {{ contract_address: address1 }}; + + assert_eq!(@contract0.get(), @100, "contract0.get() == 100"); + assert_eq!(@contract1.get(), @200, "contract1.get() == 200"); + @contract1.increase(200); + assert_eq!(@contract0.get(), @100, "contract0.get() == 100"); + assert_eq!(@contract1.get(), @400, "contract1.get() == 400"); + }} + }} + "#} + .as_str(), + ) + .unwrap(); + + Scarb::quick_snapbox() + .arg("build") + .arg("--test") + .current_dir(&t) + .assert() + .success() + .stdout_matches(indoc! {r#" + [..]Compiling test(hello_unittest) hello v0.1.0 ([..]Scarb.toml) + [..]Compiling test(hello_integrationtest) hello_integrationtest v0.1.0 ([..]Scarb.toml) + [..] Finished `dev` profile target(s) in [..] + "#}); + + assert_eq!( + t.child("target/dev").files(), + vec![ + "hello_integrationtest.test.json", + "hello_integrationtest.test.sierra.json", + "hello_integrationtest.test.starknet_artifacts.json", + "hello_integrationtest_Balance.test.contract_class.json", + "hello_integrationtest_FortyTwo.test.contract_class.json", + "hello_integrationtest_HelloContract.test.contract_class.json", + "hello_unittest.test.json", + "hello_unittest.test.sierra.json", + "hello_unittest.test.starknet_artifacts.json", + "hello_unittest_Balance.test.contract_class.json", + "hello_unittest_FortyTwo.test.contract_class.json" + ] + ); + + for json in [ + "hello_integrationtest_Balance.test.contract_class.json", + "hello_integrationtest_FortyTwo.test.contract_class.json", + "hello_integrationtest_HelloContract.test.contract_class.json", + "hello_unittest_Balance.test.contract_class.json", + "hello_unittest_FortyTwo.test.contract_class.json", + ] { + t.child("target/dev") + .child(json) + .assert_is_json::(); + } + + t.child("target/dev/hello_integrationtest.test.starknet_artifacts.json") + .assert_is_json::(); + t.child("target/dev/hello_unittest.test.starknet_artifacts.json") + .assert_is_json::(); +} + +#[test] +fn test_target_builds_external() { + let t = TempDir::new().unwrap(); + ProjectBuilder::start() + .name("first") + .version("0.1.0") + .manifest_extra(indoc! {r#" + [lib] + [[target.starknet-contract]] + "#}) + .dep_starknet() + .dep_cairo_test() + .lib_cairo(HELLO_CONTRACT) + .build(&t.child("first")); + + ProjectBuilder::start() + .name("hello") + .version("0.1.0") + .manifest_extra(indoc! {r#" + [lib] + sierra = true + + [[target.starknet-contract]] + build-external-contracts = ["first::*"] + "#}) + .dep("first", Dep.path("../first")) + .dep_starknet() + .dep_cairo_test() + .build(&t.child("hello")); + + Scarb::quick_snapbox() + .arg("build") + .arg("--test") + .current_dir(t.child("hello")) + .assert() + .success() + .stdout_matches(indoc! {r#" + [..]Compiling test(hello_unittest) hello v0.1.0 ([..]Scarb.toml) + [..] Finished `dev` profile target(s) in [..] + "#}); + + assert_eq!( + t.child("hello/target/dev").files(), + vec![ + "hello_unittest.test.json", + "hello_unittest.test.sierra.json", + "hello_unittest.test.starknet_artifacts.json", + "hello_unittest_HelloContract.test.contract_class.json" + ] + ); + + t.child("hello/target/dev/hello_unittest_HelloContract.test.contract_class.json") + .assert_is_json::(); + + t.child("hello/target/dev/hello_unittest.test.starknet_artifacts.json") + .assert_is_json::(); +}