1use super::ScriptCommandEnvMap;
7#[cfg(feature = "config-schema")]
8use crate::config::elements::slow_timeout_schema;
9use crate::{
10 config::{
11 core::{ConfigIdentifier, EvaluatableProfile, FinalConfig, PreBuildPlatform},
12 elements::{LeakTimeout, SlowTimeout, SlowTimeoutResult, deserialize_slow_timeout},
13 overrides::{MaybeTargetSpec, PlatformStrings},
14 },
15 double_spawn::{DoubleSpawnContext, DoubleSpawnInfo},
16 errors::{
17 ChildStartError, ConfigCompileError, ConfigCompileErrorKind, ConfigCompileSection,
18 ConfigParseErrorKind, InvalidConfigScriptName,
19 },
20 helpers::convert_rel_path_to_main_sep,
21 list::TestList,
22 platform::BuildPlatforms,
23 reporter::events::SetupScriptEnvMap,
24 test_command::{apply_ld_dyld_env, create_command, spawn_piped},
25};
26use camino::Utf8Path;
27use camino_tempfile::Utf8TempPath;
28use guppy::graph::cargo::BuildPlatform;
29use iddqd::{IdOrdItem, id_upcast};
30use indexmap::IndexMap;
31use nextest_filtering::{
32 BinaryQuery, EvalContext, Filterset, FiltersetKind, KnownGroups, ParseContext, TestQuery,
33};
34use quick_junit::ReportUuid;
35use serde::{Deserialize, de::Error};
36use smol_str::SmolStr;
37use std::{
38 collections::{BTreeSet, HashMap, HashSet},
39 fmt,
40 num::NonZeroUsize,
41 process::Command,
42 sync::Arc,
43 time::Duration,
44};
45use swrite::{SWrite, swrite};
46
47#[derive(Clone, Debug, Default, Deserialize)]
49#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
50#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
51#[serde(rename_all = "kebab-case")]
52pub struct ScriptConfig {
53 #[serde(default)]
56 pub setup: IndexMap<ScriptId, SetupScriptConfig>,
57 #[serde(default)]
59 pub wrapper: IndexMap<ScriptId, WrapperScriptConfig>,
60}
61
62impl ScriptConfig {
63 pub(in crate::config) fn is_empty(&self) -> bool {
64 self.setup.is_empty() && self.wrapper.is_empty()
65 }
66
67 pub(in crate::config) fn script_info(&self, id: ScriptId) -> ScriptInfo {
71 let script_type = if self.setup.contains_key(&id) {
72 ScriptType::Setup
73 } else if self.wrapper.contains_key(&id) {
74 ScriptType::Wrapper
75 } else {
76 panic!("ScriptConfig::script_info called with invalid script ID: {id}")
77 };
78
79 ScriptInfo {
80 id: id.clone(),
81 script_type,
82 }
83 }
84
85 pub(in crate::config) fn all_script_ids(&self) -> impl Iterator<Item = &ScriptId> {
87 self.setup.keys().chain(self.wrapper.keys())
88 }
89
90 pub(in crate::config) fn check_duplicate_ids(&self) -> Result<(), ConfigParseErrorKind> {
93 let duplicate_ids: BTreeSet<_> = self
94 .wrapper
95 .keys()
96 .filter(|k| self.setup.contains_key(*k))
97 .cloned()
98 .collect();
99 if duplicate_ids.is_empty() {
100 Ok(())
101 } else {
102 Err(ConfigParseErrorKind::DuplicateConfigScriptNames(
103 duplicate_ids,
104 ))
105 }
106 }
107}
108
109#[derive(Clone, Debug)]
111pub struct ScriptInfo {
112 pub id: ScriptId,
114
115 pub script_type: ScriptType,
117}
118
119impl IdOrdItem for ScriptInfo {
120 type Key<'a> = &'a ScriptId;
121 fn key(&self) -> Self::Key<'_> {
122 &self.id
123 }
124 id_upcast!();
125}
126
127#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, PartialOrd, Ord)]
129pub enum ScriptType {
130 Setup,
132
133 Wrapper,
135}
136
137impl ScriptType {
138 pub(in crate::config) fn matches(self, profile_script_type: ProfileScriptType) -> bool {
139 match self {
140 ScriptType::Setup => profile_script_type == ProfileScriptType::Setup,
141 ScriptType::Wrapper => {
142 profile_script_type == ProfileScriptType::ListWrapper
143 || profile_script_type == ProfileScriptType::RunWrapper
144 }
145 }
146 }
147}
148
149impl fmt::Display for ScriptType {
150 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
151 match self {
152 ScriptType::Setup => f.write_str("setup"),
153 ScriptType::Wrapper => f.write_str("wrapper"),
154 }
155 }
156}
157
158#[derive(Clone, Copy, Debug, Eq, PartialEq)]
160pub enum ProfileScriptType {
161 Setup,
163
164 ListWrapper,
166
167 RunWrapper,
169}
170
171impl fmt::Display for ProfileScriptType {
172 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
173 match self {
174 ProfileScriptType::Setup => f.write_str("setup"),
175 ProfileScriptType::ListWrapper => f.write_str("list-wrapper"),
176 ProfileScriptType::RunWrapper => f.write_str("run-wrapper"),
177 }
178 }
179}
180
181pub struct SetupScripts<'profile> {
183 enabled_scripts: IndexMap<&'profile ScriptId, SetupScript<'profile>>,
184}
185
186impl<'profile> SetupScripts<'profile> {
187 pub(in crate::config) fn new(
188 profile: &'profile EvaluatableProfile<'_>,
189 test_list: &TestList<'_>,
190 ) -> Self {
191 Self::new_with_queries(
192 profile,
193 test_list
194 .iter_tests()
195 .filter(|test| test.test_info.filter_match.is_match())
196 .map(|test| test.to_test_query()),
197 )
198 }
199
200 fn new_with_queries<'a>(
202 profile: &'profile EvaluatableProfile<'_>,
203 matching_tests: impl IntoIterator<Item = TestQuery<'a>>,
204 ) -> Self {
205 let script_config = profile.script_config();
206 let profile_scripts = &profile.compiled_data.scripts;
207 if profile_scripts.is_empty() {
208 return Self {
209 enabled_scripts: IndexMap::new(),
210 };
211 }
212
213 let mut by_script_id = HashMap::new();
215 for profile_script in profile_scripts {
216 for script_id in &profile_script.setup {
217 by_script_id
218 .entry(script_id)
219 .or_insert_with(Vec::new)
220 .push(profile_script);
221 }
222 }
223
224 let env = profile.filterset_ecx();
225
226 let mut enabled_ids = HashSet::new();
228 for test in matching_tests {
229 for (&script_id, compiled) in &by_script_id {
231 if enabled_ids.contains(script_id) {
232 continue;
234 }
235 if compiled.iter().any(|data| data.is_enabled(&test, &env)) {
236 enabled_ids.insert(script_id);
237 }
238 }
239 }
240
241 let mut enabled_scripts = IndexMap::new();
243 for (script_id, config) in &script_config.setup {
244 if enabled_ids.contains(script_id) {
245 let compiled = by_script_id
246 .remove(script_id)
247 .expect("script id must be present");
248 enabled_scripts.insert(
249 script_id,
250 SetupScript {
251 id: script_id.clone(),
252 config,
253 compiled,
254 },
255 );
256 }
257 }
258
259 Self { enabled_scripts }
260 }
261
262 #[inline]
264 pub fn len(&self) -> usize {
265 self.enabled_scripts.len()
266 }
267
268 #[inline]
270 pub fn is_empty(&self) -> bool {
271 self.enabled_scripts.is_empty()
272 }
273
274 #[inline]
276 pub(crate) fn into_iter(self) -> impl Iterator<Item = SetupScript<'profile>> {
277 self.enabled_scripts.into_values()
278 }
279}
280
281#[derive(Clone, Debug)]
285#[non_exhaustive]
286pub(crate) struct SetupScript<'profile> {
287 pub(crate) id: ScriptId,
289
290 pub(crate) config: &'profile SetupScriptConfig,
292
293 pub(crate) compiled: Vec<&'profile CompiledProfileScripts<FinalConfig>>,
295}
296
297impl SetupScript<'_> {
298 pub(crate) fn is_enabled(&self, test: &TestQuery<'_>, cx: &EvalContext<'_>) -> bool {
299 self.compiled
300 .iter()
301 .any(|compiled| compiled.is_enabled(test, cx))
302 }
303}
304
305pub(crate) struct SetupScriptCommand {
307 command: std::process::Command,
309 env_path: Utf8TempPath,
311 double_spawn: Option<DoubleSpawnContext>,
313}
314
315impl SetupScriptCommand {
316 pub(crate) fn new(
318 config: &SetupScriptConfig,
319 profile_name: &str,
320 double_spawn: &DoubleSpawnInfo,
321 test_list: &TestList<'_>,
322 ) -> Result<Self, ChildStartError> {
323 let mut cmd = create_command(
324 config.command.program(
325 test_list.workspace_root(),
326 &test_list.rust_build_meta().target_directory,
327 ),
328 &config.command.args,
329 double_spawn,
330 );
331
332 test_list.cargo_env().apply_env(&mut cmd);
336 config.command.env.apply_env(&mut cmd);
337
338 let env_path = camino_tempfile::Builder::new()
339 .prefix("nextest-env")
340 .tempfile()
341 .map_err(|error| ChildStartError::TempPath(Arc::new(error)))?
342 .into_temp_path();
343
344 cmd.current_dir(test_list.workspace_root())
345 .env("NEXTEST", "1")
347 .env("NEXTEST_PROFILE", profile_name)
349 .env("NEXTEST_ENV", &env_path);
351
352 apply_ld_dyld_env(&mut cmd, test_list.updated_dylib_path());
353
354 let double_spawn = double_spawn.spawn_context();
355
356 Ok(Self {
357 command: cmd,
358 env_path,
359 double_spawn,
360 })
361 }
362
363 #[inline]
365 pub(crate) fn command_mut(&mut self) -> &mut std::process::Command {
366 &mut self.command
367 }
368
369 pub(crate) fn spawn(
370 self,
371 capture_stdout: bool,
372 capture_stderr: bool,
373 ) -> std::io::Result<(tokio::process::Child, Utf8TempPath)> {
374 let res = spawn_piped(self.command, capture_stdout, capture_stderr);
375 if let Some(ctx) = self.double_spawn {
376 ctx.finish();
377 }
378 let child = res?;
379 Ok((child, self.env_path))
380 }
381}
382
383#[derive(Clone, Debug, Default)]
385pub(crate) struct SetupScriptExecuteData<'profile> {
386 env_maps: Vec<(SetupScript<'profile>, SetupScriptEnvMap)>,
387}
388
389impl<'profile> SetupScriptExecuteData<'profile> {
390 pub(crate) fn new() -> Self {
391 Self::default()
392 }
393
394 pub(crate) fn add_script(&mut self, script: SetupScript<'profile>, env_map: SetupScriptEnvMap) {
395 self.env_maps.push((script, env_map));
396 }
397
398 pub(crate) fn apply(&self, test: &TestQuery<'_>, cx: &EvalContext<'_>, command: &mut Command) {
400 for (script, env_map) in &self.env_maps {
401 if script.is_enabled(test, cx) {
402 for (key, value) in env_map.env_map.iter() {
403 command.env(key, value);
404 }
405 }
406 }
407 }
408}
409
410#[derive(Clone, Debug)]
411pub(crate) struct CompiledProfileScripts<State> {
412 pub(in crate::config) setup: Vec<ScriptId>,
413 pub(in crate::config) list_wrapper: Option<ScriptId>,
414 pub(in crate::config) run_wrapper: Option<ScriptId>,
415 pub(in crate::config) data: ProfileScriptData,
416 pub(in crate::config) state: State,
417}
418
419impl CompiledProfileScripts<PreBuildPlatform> {
420 pub(in crate::config) fn new(
421 pcx: &ParseContext<'_>,
422 profile_name: &str,
423 index: usize,
424 source: &DeserializedProfileScriptConfig,
425 errors: &mut Vec<ConfigCompileError>,
426 ) -> Option<Self> {
427 if source.platform.host.is_none()
428 && source.platform.target.is_none()
429 && source.filter.is_none()
430 {
431 errors.push(ConfigCompileError {
432 profile_name: profile_name.to_owned(),
433 section: ConfigCompileSection::Script(index),
434 kind: ConfigCompileErrorKind::ConstraintsNotSpecified {
435 default_filter_specified: false,
438 },
439 });
440 return None;
441 }
442
443 let host_spec = MaybeTargetSpec::new(source.platform.host.as_deref());
444 let target_spec = MaybeTargetSpec::new(source.platform.target.as_deref());
445
446 let filter_expr = source.filter.as_ref().map_or(Ok(None), |filter| {
447 Some(Filterset::parse(
450 filter.clone(),
451 pcx,
452 FiltersetKind::DefaultFilter,
453 &KnownGroups::Unavailable,
454 ))
455 .transpose()
456 });
457
458 match (host_spec, target_spec, filter_expr) {
459 (Ok(host_spec), Ok(target_spec), Ok(expr)) => Some(Self {
460 setup: source.setup.clone(),
461 list_wrapper: source.list_wrapper.clone(),
462 run_wrapper: source.run_wrapper.clone(),
463 data: ProfileScriptData {
464 host_spec,
465 target_spec,
466 expr,
467 },
468 state: PreBuildPlatform {},
469 }),
470 (maybe_host_err, maybe_platform_err, maybe_parse_err) => {
471 let host_platform_parse_error = maybe_host_err.err();
472 let platform_parse_error = maybe_platform_err.err();
473 let parse_errors = maybe_parse_err.err();
474
475 errors.push(ConfigCompileError {
476 profile_name: profile_name.to_owned(),
477 section: ConfigCompileSection::Script(index),
478 kind: ConfigCompileErrorKind::Parse {
479 host_parse_error: host_platform_parse_error,
480 target_parse_error: platform_parse_error,
481 filter_parse_errors: parse_errors.into_iter().collect(),
482 },
483 });
484 None
485 }
486 }
487 }
488
489 pub(in crate::config) fn apply_build_platforms(
490 self,
491 build_platforms: &BuildPlatforms,
492 ) -> CompiledProfileScripts<FinalConfig> {
493 let host_eval = self.data.host_spec.eval(&build_platforms.host.platform);
494 let host_test_eval = self.data.target_spec.eval(&build_platforms.host.platform);
495 let target_eval = build_platforms
496 .target
497 .as_ref()
498 .map_or(host_test_eval, |target| {
499 self.data.target_spec.eval(&target.triple.platform)
500 });
501
502 CompiledProfileScripts {
503 setup: self.setup,
504 list_wrapper: self.list_wrapper,
505 run_wrapper: self.run_wrapper,
506 data: self.data,
507 state: FinalConfig {
508 host_eval,
509 host_test_eval,
510 target_eval,
511 },
512 }
513 }
514}
515
516impl CompiledProfileScripts<FinalConfig> {
517 pub(in crate::config) fn is_enabled_binary(
518 &self,
519 query: &BinaryQuery<'_>,
520 cx: &EvalContext<'_>,
521 ) -> Option<bool> {
522 if !self.state.host_eval {
523 return Some(false);
524 }
525 if query.platform == BuildPlatform::Host && !self.state.host_test_eval {
526 return Some(false);
527 }
528 if query.platform == BuildPlatform::Target && !self.state.target_eval {
529 return Some(false);
530 }
531
532 if let Some(expr) = &self.data.expr {
533 expr.matches_binary(query, cx)
534 } else {
535 Some(true)
536 }
537 }
538
539 pub(in crate::config) fn is_enabled(
540 &self,
541 query: &TestQuery<'_>,
542 cx: &EvalContext<'_>,
543 ) -> bool {
544 if !self.state.host_eval {
545 return false;
546 }
547 if query.binary_query.platform == BuildPlatform::Host && !self.state.host_test_eval {
548 return false;
549 }
550 if query.binary_query.platform == BuildPlatform::Target && !self.state.target_eval {
551 return false;
552 }
553
554 if let Some(expr) = &self.data.expr {
555 expr.matches_test(query, cx)
556 } else {
557 true
558 }
559 }
560}
561
562#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, serde::Serialize)]
564#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
565#[serde(transparent)]
566pub struct ScriptId(
567 #[cfg_attr(
568 feature = "config-schema",
569 schemars(schema_with = "String::json_schema")
570 )]
571 pub ConfigIdentifier,
572);
573
574impl ScriptId {
575 pub fn new(identifier: SmolStr) -> Result<Self, InvalidConfigScriptName> {
577 let identifier = ConfigIdentifier::new(identifier).map_err(InvalidConfigScriptName)?;
578 Ok(Self(identifier))
579 }
580
581 pub fn as_identifier(&self) -> &ConfigIdentifier {
583 &self.0
584 }
585
586 pub fn unique_id(&self, run_id: ReportUuid, stress_index: Option<u32>) -> String {
588 let mut out = String::new();
589 swrite!(out, "{run_id}:{self}");
590 if let Some(stress_index) = stress_index {
591 swrite!(out, "@stress-{}", stress_index);
592 }
593 out
594 }
595
596 #[cfg(test)]
597 pub(super) fn as_str(&self) -> &str {
598 self.0.as_str()
599 }
600}
601
602impl<'de> Deserialize<'de> for ScriptId {
603 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
604 where
605 D: serde::Deserializer<'de>,
606 {
607 let identifier = SmolStr::deserialize(deserializer)?;
609 Self::new(identifier).map_err(serde::de::Error::custom)
610 }
611}
612
613impl fmt::Display for ScriptId {
614 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
615 write!(f, "{}", self.0)
616 }
617}
618
619#[derive(Clone, Debug)]
620pub(in crate::config) struct ProfileScriptData {
621 host_spec: MaybeTargetSpec,
622 target_spec: MaybeTargetSpec,
623 expr: Option<Filterset>,
624}
625
626impl ProfileScriptData {
627 pub(in crate::config) fn expr(&self) -> Option<&Filterset> {
628 self.expr.as_ref()
629 }
630}
631
632#[derive(Clone, Debug, Deserialize)]
634#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
635#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
636#[serde(rename_all = "kebab-case")]
637pub(in crate::config) struct DeserializedProfileScriptConfig {
638 #[serde(default)]
640 pub(in crate::config) platform: PlatformStrings,
641
642 #[serde(default)]
644 filter: Option<String>,
645
646 #[cfg_attr(feature = "config-schema", schemars(schema_with = "script_ids_schema"))]
648 #[serde(default, deserialize_with = "deserialize_script_ids")]
649 setup: Vec<ScriptId>,
650
651 #[serde(default)]
653 list_wrapper: Option<ScriptId>,
654
655 #[serde(default)]
657 run_wrapper: Option<ScriptId>,
658}
659
660#[cfg(feature = "config-schema")]
661fn script_ids_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
662 schemars::json_schema!({
663 "oneOf": [
664 generator.subschema_for::<ScriptId>(),
665 {
666 "type": "array",
667 "items": generator.subschema_for::<ScriptId>(),
668 }
669 ]
670 })
671}
672
673#[derive(Clone, Debug, Deserialize)]
677#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
678#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
679#[serde(rename_all = "kebab-case")]
680pub struct SetupScriptConfig {
681 pub command: ScriptCommand,
683
684 #[serde(default, deserialize_with = "deserialize_setup_script_slow_timeout")]
686 pub slow_timeout: Option<SetupScriptSlowTimeout>,
687
688 #[serde(
690 default,
691 deserialize_with = "crate::config::elements::deserialize_leak_timeout"
692 )]
693 pub leak_timeout: Option<LeakTimeout>,
694
695 #[serde(default)]
697 pub capture_stdout: bool,
698
699 #[serde(default)]
701 pub capture_stderr: bool,
702
703 #[serde(default)]
705 pub junit: SetupScriptJunitConfig,
706}
707
708impl SetupScriptConfig {
709 #[inline]
711 pub fn no_capture(&self) -> bool {
712 !(self.capture_stdout && self.capture_stderr)
713 }
714}
715
716#[derive(Clone, Copy, Debug, PartialEq, Eq)]
722pub struct SetupScriptSlowTimeout {
723 pub(crate) period: Duration,
724 pub(crate) terminate_after: Option<NonZeroUsize>,
725 pub(crate) grace_period: Duration,
726}
727
728impl SetupScriptSlowTimeout {
729 pub(crate) const VERY_LARGE: Self = Self {
730 period: SlowTimeout::VERY_LARGE.period,
731 terminate_after: SlowTimeout::VERY_LARGE.terminate_after,
732 grace_period: SlowTimeout::VERY_LARGE.grace_period,
733 };
734}
735
736#[cfg(feature = "config-schema")]
737impl schemars::JsonSchema for SetupScriptSlowTimeout {
738 fn schema_name() -> std::borrow::Cow<'static, str> {
739 "SetupScriptSlowTimeout".into()
740 }
741
742 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
743 let on_timeout = schemars::json_schema!({
744 "description": "A setup script that times out always fails, so only \"fail\" is accepted.",
745 "type": "string",
746 "const": "fail",
747 });
748 slow_timeout_schema(generator, "SetupScriptSlowTimeout", on_timeout)
749 }
750}
751
752fn deserialize_setup_script_slow_timeout<'de, D>(
753 deserializer: D,
754) -> Result<Option<SetupScriptSlowTimeout>, D::Error>
755where
756 D: serde::Deserializer<'de>,
757{
758 let Some(slow_timeout) = deserialize_slow_timeout(deserializer)? else {
759 return Ok(None);
760 };
761 let SlowTimeout {
762 period,
763 terminate_after,
764 grace_period,
765 on_timeout,
766 } = slow_timeout;
767
768 match on_timeout {
769 SlowTimeoutResult::Fail => Ok(Some(SetupScriptSlowTimeout {
770 period,
771 terminate_after,
772 grace_period,
773 })),
774 SlowTimeoutResult::Pass => Err(D::Error::custom(
775 "`on-timeout = \"pass\"` is not supported for setup scripts: \
776 a setup script that times out always fails the run\n\
777 (hint: remove `on-timeout` or set it to `\"fail\"`)",
778 )),
779 }
780}
781
782#[derive(Copy, Clone, Debug, Deserialize)]
784#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
785#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
786#[serde(rename_all = "kebab-case")]
787pub struct SetupScriptJunitConfig {
788 #[serde(default = "default_true")]
791 pub store_success_output: bool,
792
793 #[serde(default = "default_true")]
796 pub store_failure_output: bool,
797}
798
799impl Default for SetupScriptJunitConfig {
800 fn default() -> Self {
801 Self {
802 store_success_output: true,
803 store_failure_output: true,
804 }
805 }
806}
807
808#[derive(Clone, Debug, Deserialize)]
812#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
813#[cfg_attr(feature = "config-schema", schemars(deny_unknown_fields))]
814#[serde(rename_all = "kebab-case")]
815pub struct WrapperScriptConfig {
816 pub command: ScriptCommand,
818
819 #[serde(default)]
821 pub target_runner: WrapperScriptTargetRunner,
822}
823
824#[derive(Clone, Debug, Default)]
826#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
827#[cfg_attr(feature = "config-schema", schemars(rename_all = "kebab-case"))]
828pub enum WrapperScriptTargetRunner {
829 #[default]
831 Ignore,
832
833 OverridesWrapper,
836
837 WithinWrapper,
840
841 AroundWrapper,
844}
845
846impl<'de> Deserialize<'de> for WrapperScriptTargetRunner {
847 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
848 where
849 D: serde::Deserializer<'de>,
850 {
851 let s = String::deserialize(deserializer)?;
852 match s.as_str() {
853 "ignore" => Ok(WrapperScriptTargetRunner::Ignore),
854 "overrides-wrapper" => Ok(WrapperScriptTargetRunner::OverridesWrapper),
855 "within-wrapper" => Ok(WrapperScriptTargetRunner::WithinWrapper),
856 "around-wrapper" => Ok(WrapperScriptTargetRunner::AroundWrapper),
857 _ => Err(serde::de::Error::unknown_variant(
858 &s,
859 &[
860 "ignore",
861 "overrides-wrapper",
862 "within-wrapper",
863 "around-wrapper",
864 ],
865 )),
866 }
867 }
868}
869
870fn default_true() -> bool {
871 true
872}
873
874fn deserialize_script_ids<'de, D>(deserializer: D) -> Result<Vec<ScriptId>, D::Error>
875where
876 D: serde::Deserializer<'de>,
877{
878 struct ScriptIdVisitor;
879
880 impl<'de> serde::de::Visitor<'de> for ScriptIdVisitor {
881 type Value = Vec<ScriptId>;
882
883 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
884 formatter.write_str("a script ID (string) or a list of script IDs")
885 }
886
887 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
888 where
889 E: serde::de::Error,
890 {
891 Ok(vec![ScriptId::new(value.into()).map_err(E::custom)?])
892 }
893
894 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
895 where
896 A: serde::de::SeqAccess<'de>,
897 {
898 let mut ids = Vec::new();
899 while let Some(value) = seq.next_element::<String>()? {
900 ids.push(ScriptId::new(value.into()).map_err(A::Error::custom)?);
901 }
902 Ok(ids)
903 }
904 }
905
906 deserializer.deserialize_any(ScriptIdVisitor)
907}
908
909#[derive(Clone, Debug)]
911pub struct ScriptCommand {
912 pub program: String,
914
915 pub args: Vec<String>,
917
918 pub env: ScriptCommandEnvMap,
920
921 pub relative_to: ScriptCommandRelativeTo,
926}
927
928impl ScriptCommand {
929 pub fn program(&self, workspace_root: &Utf8Path, target_dir: &Utf8Path) -> String {
931 match self.relative_to {
932 ScriptCommandRelativeTo::None => self.program.clone(),
933 ScriptCommandRelativeTo::WorkspaceRoot => {
934 let path = Utf8Path::new(&self.program);
936 if path.is_relative() {
937 workspace_root
938 .join(convert_rel_path_to_main_sep(path))
939 .to_string()
940 } else {
941 path.to_string()
942 }
943 }
944 ScriptCommandRelativeTo::Target => {
945 let path = Utf8Path::new(&self.program);
947 if path.is_relative() {
948 target_dir
949 .join(convert_rel_path_to_main_sep(path))
950 .to_string()
951 } else {
952 path.to_string()
953 }
954 }
955 }
956 }
957}
958
959impl<'de> Deserialize<'de> for ScriptCommand {
960 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
961 where
962 D: serde::Deserializer<'de>,
963 {
964 struct CommandVisitor;
965
966 impl<'de> serde::de::Visitor<'de> for CommandVisitor {
967 type Value = ScriptCommand;
968
969 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
970 formatter.write_str("a Unix shell command, a list of arguments, or a table with command-line, env, and relative-to")
971 }
972
973 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
974 where
975 E: serde::de::Error,
976 {
977 let mut args = shell_words::split(value).map_err(E::custom)?;
978 if args.is_empty() {
979 return Err(E::invalid_value(serde::de::Unexpected::Str(value), &self));
980 }
981 let program = args.remove(0);
982 Ok(ScriptCommand {
983 program,
984 args,
985 env: ScriptCommandEnvMap::default(),
986 relative_to: ScriptCommandRelativeTo::None,
987 })
988 }
989
990 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
991 where
992 A: serde::de::SeqAccess<'de>,
993 {
994 let Some(program) = seq.next_element::<String>()? else {
995 return Err(A::Error::invalid_length(0, &self));
996 };
997 let mut args = Vec::new();
998 while let Some(value) = seq.next_element::<String>()? {
999 args.push(value);
1000 }
1001 Ok(ScriptCommand {
1002 program,
1003 args,
1004 env: ScriptCommandEnvMap::default(),
1005 relative_to: ScriptCommandRelativeTo::None,
1006 })
1007 }
1008
1009 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
1010 where
1011 A: serde::de::MapAccess<'de>,
1012 {
1013 let mut command_line = None;
1014 let mut relative_to = None;
1015 let mut env = None;
1016
1017 while let Some(key) = map.next_key::<String>()? {
1018 match key.as_str() {
1019 "command-line" => {
1020 if command_line.is_some() {
1021 return Err(A::Error::duplicate_field("command-line"));
1022 }
1023 command_line = Some(map.next_value_seed(CommandInnerSeed)?);
1024 }
1025 "relative-to" => {
1026 if relative_to.is_some() {
1027 return Err(A::Error::duplicate_field("relative-to"));
1028 }
1029 relative_to = Some(map.next_value::<ScriptCommandRelativeTo>()?);
1030 }
1031 "env" => {
1032 if env.is_some() {
1033 return Err(A::Error::duplicate_field("env"));
1034 }
1035 env = Some(map.next_value::<ScriptCommandEnvMap>()?);
1036 }
1037 _ => {
1038 return Err(A::Error::unknown_field(
1039 &key,
1040 &["command-line", "env", "relative-to"],
1041 ));
1042 }
1043 }
1044 }
1045
1046 let (program, arguments) =
1047 command_line.ok_or_else(|| A::Error::missing_field("command-line"))?;
1048 let env = env.unwrap_or_default();
1049 let relative_to = relative_to.unwrap_or(ScriptCommandRelativeTo::None);
1050
1051 Ok(ScriptCommand {
1052 program,
1053 args: arguments,
1054 env,
1055 relative_to,
1056 })
1057 }
1058 }
1059
1060 deserializer.deserialize_any(CommandVisitor)
1061 }
1062}
1063
1064#[cfg(feature = "config-schema")]
1065impl schemars::JsonSchema for ScriptCommand {
1066 fn schema_name() -> std::borrow::Cow<'static, str> {
1067 "ScriptCommand".into()
1068 }
1069
1070 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
1071 fn non_empty_string_array_schema(
1072 generator: &mut schemars::SchemaGenerator,
1073 ) -> schemars::Schema {
1074 schemars::json_schema!({
1075 "type": "array",
1076 "items": generator.subschema_for::<String>(),
1077 "minItems": 1,
1078 })
1079 }
1080
1081 schemars::json_schema!({
1082 "title": "ScriptCommand",
1083 "oneOf": [
1084 generator.subschema_for::<String>(),
1085 non_empty_string_array_schema(generator),
1086 {
1087 "type": "object",
1088 "properties": {
1089 "command-line": {
1090 "oneOf": [
1091 generator.subschema_for::<String>(),
1092 non_empty_string_array_schema(generator),
1093 ]
1094 },
1095 "env": generator.subschema_for::<std::collections::BTreeMap<String, String>>(),
1096 "relative-to": generator.subschema_for::<ScriptCommandRelativeTo>(),
1097 },
1098 "required": ["command-line"],
1099 "additionalProperties": false,
1100 }
1101 ]
1102 })
1103 }
1104}
1105
1106struct CommandInnerSeed;
1107
1108impl<'de> serde::de::DeserializeSeed<'de> for CommandInnerSeed {
1109 type Value = (String, Vec<String>);
1110
1111 fn deserialize<D>(self, deserializer: D) -> Result<Self::Value, D::Error>
1112 where
1113 D: serde::Deserializer<'de>,
1114 {
1115 struct CommandInnerVisitor;
1116
1117 impl<'de> serde::de::Visitor<'de> for CommandInnerVisitor {
1118 type Value = (String, Vec<String>);
1119
1120 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1121 formatter.write_str("a string or array of strings")
1122 }
1123
1124 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1125 where
1126 E: serde::de::Error,
1127 {
1128 let mut args = shell_words::split(value).map_err(E::custom)?;
1129 if args.is_empty() {
1130 return Err(E::invalid_value(
1131 serde::de::Unexpected::Str(value),
1132 &"a non-empty command string",
1133 ));
1134 }
1135 let program = args.remove(0);
1136 Ok((program, args))
1137 }
1138
1139 fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
1140 where
1141 S: serde::de::SeqAccess<'de>,
1142 {
1143 let mut args = Vec::new();
1144 while let Some(value) = seq.next_element::<String>()? {
1145 args.push(value);
1146 }
1147 if args.is_empty() {
1148 return Err(S::Error::invalid_length(0, &self));
1149 }
1150 let program = args.remove(0);
1151 Ok((program, args))
1152 }
1153 }
1154
1155 deserializer.deserialize_any(CommandInnerVisitor)
1156 }
1157}
1158
1159#[derive(Clone, Copy, Debug)]
1163#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
1164#[cfg_attr(feature = "config-schema", schemars(rename_all = "kebab-case"))]
1165pub enum ScriptCommandRelativeTo {
1166 None,
1168
1169 WorkspaceRoot,
1171
1172 Target,
1174 }
1176
1177impl<'de> Deserialize<'de> for ScriptCommandRelativeTo {
1178 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1179 where
1180 D: serde::Deserializer<'de>,
1181 {
1182 let s = String::deserialize(deserializer)?;
1183 match s.as_str() {
1184 "none" => Ok(ScriptCommandRelativeTo::None),
1185 "workspace-root" => Ok(ScriptCommandRelativeTo::WorkspaceRoot),
1186 "target" => Ok(ScriptCommandRelativeTo::Target),
1187 _ => Err(serde::de::Error::unknown_variant(&s, &["none", "target"])),
1188 }
1189 }
1190}
1191
1192#[cfg(test)]
1193mod tests {
1194 use super::*;
1195 use crate::{
1196 config::{
1197 core::{ConfigExperimental, NextestConfig, ToolConfigFile, ToolName},
1198 utils::test_helpers::*,
1199 },
1200 errors::{
1201 ConfigParseErrorKind, DisplayErrorChain, ProfileListScriptUsesRunFiltersError,
1202 ProfileScriptErrors, ProfileUnknownScriptError, ProfileWrongConfigScriptTypeError,
1203 },
1204 };
1205 use camino_tempfile::tempdir;
1206 use camino_tempfile_ext::prelude::*;
1207 use indoc::indoc;
1208 use maplit::btreeset;
1209 use nextest_metadata::TestCaseName;
1210 use test_case::test_case;
1211
1212 fn tool_name(s: &str) -> ToolName {
1213 ToolName::new(s.into()).unwrap()
1214 }
1215
1216 #[test]
1217 fn test_scripts_basic() {
1218 let config_contents = indoc! {r#"
1219 [[profile.default.scripts]]
1220 platform = { host = "x86_64-unknown-linux-gnu" }
1221 filter = "test(script1)"
1222 setup = ["foo", "bar"]
1223
1224 [[profile.default.scripts]]
1225 platform = { target = "aarch64-apple-darwin" }
1226 filter = "test(script2)"
1227 setup = "baz"
1228
1229 [[profile.default.scripts]]
1230 filter = "test(script3)"
1231 # No matter which order scripts are specified here, they must always be run in the
1232 # order defined below.
1233 setup = ["baz", "foo", "@tool:my-tool:toolscript"]
1234
1235 [[profile.default.scripts]]
1236 filter = "test(script4)"
1237 setup = "qux"
1238
1239 [scripts.setup.foo]
1240 command = "command foo"
1241
1242 [scripts.setup.bar]
1243 command = ["cargo", "run", "-p", "bar"]
1244 slow-timeout = { period = "60s", terminate-after = 2 }
1245
1246 [scripts.setup.baz]
1247 command = "baz"
1248 slow-timeout = "1s"
1249 leak-timeout = "1s"
1250 capture-stdout = true
1251 capture-stderr = true
1252
1253 [scripts.setup.qux]
1254 command = {
1255 command-line = "qux",
1256 env = {
1257 MODE = "qux_mode",
1258 },
1259 }
1260 "#
1261 };
1262
1263 let tool_config_contents = indoc! {r#"
1264 [scripts.setup.'@tool:my-tool:toolscript']
1265 command = "tool-command"
1266 "#
1267 };
1268
1269 let workspace_dir = tempdir().unwrap();
1270
1271 let graph = temp_workspace(&workspace_dir, config_contents);
1272 let tool_path = workspace_dir.child(".config/my-tool.toml");
1273 tool_path.write_str(tool_config_contents).unwrap();
1274
1275 let package_id = graph.workspace().iter().next().unwrap().id();
1276
1277 let pcx = ParseContext::new(&graph);
1278
1279 let tool_config_files = [ToolConfigFile {
1280 tool: tool_name("my-tool"),
1281 config_file: tool_path.to_path_buf(),
1282 }];
1283
1284 let nextest_config_error = NextestConfig::from_sources(
1286 graph.workspace().root(),
1287 &pcx,
1288 None,
1289 &tool_config_files,
1290 &Default::default(),
1291 )
1292 .unwrap_err();
1293 match nextest_config_error.kind() {
1294 ConfigParseErrorKind::ExperimentalFeaturesNotEnabled { missing_features } => {
1295 assert_eq!(
1296 *missing_features,
1297 btreeset! { ConfigExperimental::SetupScripts }
1298 );
1299 }
1300 other => panic!("unexpected error kind: {other:?}"),
1301 }
1302
1303 let nextest_config_result = NextestConfig::from_sources(
1305 graph.workspace().root(),
1306 &pcx,
1307 None,
1308 &tool_config_files,
1309 &btreeset! { ConfigExperimental::SetupScripts },
1310 )
1311 .expect("config is valid");
1312 let profile = nextest_config_result
1313 .profile("default")
1314 .expect("valid profile name")
1315 .apply_build_platforms(&build_platforms());
1316
1317 let host_binary_query =
1319 binary_query(&graph, package_id, "lib", "my-binary", BuildPlatform::Host);
1320 let test_name = TestCaseName::new("script1");
1321 let query = TestQuery {
1322 binary_query: host_binary_query.to_query(),
1323 test_name: &test_name,
1324 };
1325 let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1326 assert_eq!(scripts.len(), 2, "two scripts should be enabled");
1327 assert_eq!(
1328 scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1329 "foo",
1330 "first script should be foo"
1331 );
1332 assert_eq!(
1333 scripts.enabled_scripts.get_index(1).unwrap().0.as_str(),
1334 "bar",
1335 "second script should be bar"
1336 );
1337
1338 let target_binary_query = binary_query(
1339 &graph,
1340 package_id,
1341 "lib",
1342 "my-binary",
1343 BuildPlatform::Target,
1344 );
1345
1346 let test_name = TestCaseName::new("script2");
1348 let query = TestQuery {
1349 binary_query: target_binary_query.to_query(),
1350 test_name: &test_name,
1351 };
1352 let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1353 assert_eq!(scripts.len(), 1, "one script should be enabled");
1354 assert_eq!(
1355 scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1356 "baz",
1357 "first script should be baz"
1358 );
1359
1360 let test_name = TestCaseName::new("script3");
1362 let query = TestQuery {
1363 binary_query: target_binary_query.to_query(),
1364 test_name: &test_name,
1365 };
1366 let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1367 assert_eq!(scripts.len(), 3, "three scripts should be enabled");
1368 assert_eq!(
1369 scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1370 "@tool:my-tool:toolscript",
1371 "first script should be toolscript"
1372 );
1373 assert_eq!(
1374 scripts.enabled_scripts.get_index(1).unwrap().0.as_str(),
1375 "foo",
1376 "second script should be foo"
1377 );
1378 assert_eq!(
1379 scripts.enabled_scripts.get_index(2).unwrap().0.as_str(),
1380 "baz",
1381 "third script should be baz"
1382 );
1383
1384 let test_name = TestCaseName::new("script4");
1386 let query = TestQuery {
1387 binary_query: target_binary_query.to_query(),
1388 test_name: &test_name,
1389 };
1390 let scripts = SetupScripts::new_with_queries(&profile, std::iter::once(query));
1391 assert_eq!(scripts.len(), 1, "one script should be enabled");
1392 assert_eq!(
1393 scripts.enabled_scripts.get_index(0).unwrap().0.as_str(),
1394 "qux",
1395 "first script should be qux"
1396 );
1397 assert_eq!(
1398 scripts
1399 .enabled_scripts
1400 .get_index(0)
1401 .unwrap()
1402 .1
1403 .config
1404 .command
1405 .env
1406 .get("MODE"),
1407 Some("qux_mode"),
1408 "first script should be passed environment variable MODE with value qux_mode",
1409 );
1410 }
1411
1412 #[test_case(
1413 indoc! {r#"
1414 [scripts.setup.foo]
1415 command = ""
1416 "#},
1417 "invalid value: string \"\", expected a Unix shell command, a list of arguments, \
1418 or a table with command-line, env, and relative-to"
1419
1420 ; "empty command"
1421 )]
1422 #[test_case(
1423 indoc! {r#"
1424 [scripts.setup.foo]
1425 command = []
1426 "#},
1427 "invalid length 0, expected a Unix shell command, a list of arguments, \
1428 or a table with command-line, env, and relative-to"
1429
1430 ; "empty command list"
1431 )]
1432 #[test_case(
1433 indoc! {r#"
1434 [scripts.setup.foo]
1435 "#},
1436 r#"scripts.setup.foo: missing configuration field "scripts.setup.foo.command""#
1437
1438 ; "missing command"
1439 )]
1440 #[test_case(
1441 indoc! {r#"
1442 [scripts.setup.foo]
1443 command = { command-line = "" }
1444 "#},
1445 "invalid value: string \"\", expected a non-empty command string"
1446
1447 ; "empty command-line in table"
1448 )]
1449 #[test_case(
1450 indoc! {r#"
1451 [scripts.setup.foo]
1452 command = { command-line = [] }
1453 "#},
1454 "invalid length 0, expected a string or array of strings"
1455
1456 ; "empty command-line array in table"
1457 )]
1458 #[test_case(
1459 indoc! {r#"
1460 [scripts.setup.foo]
1461 command = {
1462 command_line = "hi",
1463 command_line = ["hi"],
1464 }
1465 "#},
1466 r#"duplicate key"#
1467
1468 ; "command line is duplicate"
1469 )]
1470 #[test_case(
1471 indoc! {r#"
1472 [scripts.setup.foo]
1473 command = { relative-to = "target" }
1474 "#},
1475 r#"missing configuration field "scripts.setup.foo.command.command-line""#
1476
1477 ; "missing command-line in table"
1478 )]
1479 #[test_case(
1480 indoc! {r#"
1481 [scripts.setup.foo]
1482 command = { command-line = "my-command", relative-to = "invalid" }
1483 "#},
1484 r#"unknown variant `invalid`, expected `none` or `target`"#
1485
1486 ; "invalid relative-to value"
1487 )]
1488 #[test_case(
1489 indoc! {r#"
1490 [scripts.setup.foo]
1491 command = {
1492 relative-to = "none",
1493 relative-to = "target",
1494 }
1495 "#},
1496 r#"duplicate key"#
1497
1498 ; "relative to is duplicate"
1499 )]
1500 #[test_case(
1501 indoc! {r#"
1502 [scripts.setup.foo]
1503 command = { command-line = "my-command", unknown-field = "value" }
1504 "#},
1505 r#"unknown field `unknown-field`, expected one of `command-line`, `env`, `relative-to`"#
1506
1507 ; "unknown field in command table"
1508 )]
1509 #[test_case(
1510 indoc! {r#"
1511 [scripts.setup.foo]
1512 command = "my-command"
1513 slow-timeout = 34
1514 "#},
1515 r#"invalid type: integer `34`, expected a table ({ period = "60s", terminate-after = 2 }) or a string ("60s")"#
1516
1517 ; "slow timeout is not a duration"
1518 )]
1519 #[test_case(
1520 indoc! {r#"
1521 [scripts.setup.foo]
1522 command = "my-command"
1523 slow-timeout = { period = "60s", terminate-after = 2, on-timeout = "pass" }
1524 "#},
1525 r#"scripts.setup.foo.slow-timeout: `on-timeout = "pass"` is not supported for setup scripts: a setup script that times out always fails the run"#
1526
1527 ; "slow timeout on-timeout is pass"
1528 )]
1529 #[test_case(
1530 indoc! {r#"
1531 [scripts.setup.'@tool:foo']
1532 command = "my-command"
1533 "#},
1534 r#"invalid configuration script name: tool identifier not of the form "@tool:tool-name:identifier": `@tool:foo`"#
1535
1536 ; "invalid tool script name"
1537 )]
1538 #[test_case(
1539 indoc! {r#"
1540 [scripts.setup.'#foo']
1541 command = "my-command"
1542 "#},
1543 r"invalid configuration script name: invalid identifier `#foo`"
1544
1545 ; "invalid script name"
1546 )]
1547 #[test_case(
1548 indoc! {r#"
1549 [scripts.wrapper.foo]
1550 command = "my-command"
1551 target-runner = "not-a-valid-value"
1552 "#},
1553 r#"unknown variant `not-a-valid-value`, expected one of `ignore`, `overrides-wrapper`, `within-wrapper`, `around-wrapper`"#
1554
1555 ; "invalid target-runner value"
1556 )]
1557 #[test_case(
1558 indoc! {r#"
1559 [scripts.wrapper.foo]
1560 command = "my-command"
1561 target-runner = ["foo"]
1562 "#},
1563 r#"invalid type: sequence, expected a string"#
1564
1565 ; "target-runner is not a string"
1566 )]
1567 #[test_case(
1568 indoc! {r#"
1569 [scripts.setup.foo]
1570 command = {
1571 env = {},
1572 env = {},
1573 }
1574 "#},
1575 r#"duplicate key"#
1576
1577 ; "env is duplicate"
1578 )]
1579 #[test_case(
1580 indoc! {r#"
1581 [scripts.setup.foo]
1582 command = {
1583 command-line = "my-command",
1584 env = "not a map"
1585 }
1586 "#},
1587 r#"scripts.setup.foo.command.env: invalid type: string "not a map", expected a map of environment variable names to values"#
1588
1589 ; "env is not a map"
1590 )]
1591 #[test_case(
1592 indoc! {r#"
1593 [scripts.setup.foo]
1594 command = {
1595 command-line = "my-command",
1596 env = {
1597 NEXTEST_RESERVED = "reserved",
1598 },
1599 }
1600 "#},
1601 r#"scripts.setup.foo.command.env: invalid value: string "NEXTEST_RESERVED", expected a key that does not begin with `NEXTEST`, which is reserved for internal use"#
1602
1603 ; "env containing key reserved for internal use"
1604 )]
1605 #[test_case(
1606 indoc! {r#"
1607 [scripts.setup.foo]
1608 command = {
1609 command-line = "my-command",
1610 env = {
1611 42 = "answer",
1612 },
1613 }
1614 "#},
1615 r#"scripts.setup.foo.command.env: invalid value: string "42", expected a key that starts with a letter or underscore"#
1616
1617 ; "env containing key first character a digit"
1618 )]
1619 #[test_case(
1620 indoc! {r#"
1621 [scripts.setup.foo]
1622 command = {
1623 command-line = "my-command",
1624 env = {
1625 " " = "some value",
1626 },
1627 }
1628 "#},
1629 r#"scripts.setup.foo.command.env: invalid value: string " ", expected a key that starts with a letter or underscore"#
1630
1631 ; "env containing key started with an unsupported characters"
1632 )]
1633 #[test_case(
1634 indoc! {r#"
1635 [scripts.setup.foo]
1636 command = {
1637 command-line = "my-command",
1638 env = {
1639 "test=test" = "some value",
1640 },
1641 }
1642 "#},
1643 r#"scripts.setup.foo.command.env: invalid value: string "test=test", expected a key that consists solely of letters, digits, and underscores"#
1644
1645 ; "env containing key with unsupported characters"
1646 )]
1647 fn parse_scripts_invalid_deserialize(config_contents: &str, message: &str) {
1648 let workspace_dir = tempdir().unwrap();
1649
1650 let graph = temp_workspace(&workspace_dir, config_contents);
1651 let pcx = ParseContext::new(&graph);
1652
1653 let nextest_config_error = NextestConfig::from_sources(
1654 graph.workspace().root(),
1655 &pcx,
1656 None,
1657 &[][..],
1658 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1659 )
1660 .expect_err("config is invalid");
1661 let actual_message = DisplayErrorChain::new(nextest_config_error).to_string();
1662
1663 assert!(
1664 actual_message.contains(message),
1665 "nextest config error `{actual_message}` contains message `{message}`"
1666 );
1667 }
1668
1669 #[test_case(
1670 indoc! {r#"
1671 [scripts.setup.foo]
1672 command = "my-command"
1673 slow-timeout = ""
1674 "#},
1675 None
1676
1677 ; "slow timeout is an empty string"
1678 )]
1679 #[test_case(
1680 indoc! {r#"
1681 [scripts.setup.foo]
1682 command = "my-command"
1683 slow-timeout = { period = "60s", terminate-after = 2, grace-period = "1s", on-timeout = "fail" }
1684 "#},
1685 Some(SetupScriptSlowTimeout {
1686 period: Duration::from_secs(60),
1687 terminate_after: Some(NonZeroUsize::new(2).unwrap()),
1688 grace_period: Duration::from_secs(1),
1689 })
1690
1691 ; "slow timeout on-timeout is fail"
1692 )]
1693 fn parse_setup_script_slow_timeout(
1694 config_contents: &str,
1695 expected: Option<SetupScriptSlowTimeout>,
1696 ) {
1697 let workspace_dir = tempdir().unwrap();
1698
1699 let graph = temp_workspace(&workspace_dir, config_contents);
1700 let pcx = ParseContext::new(&graph);
1701
1702 let nextest_config = NextestConfig::from_sources(
1703 graph.workspace().root(),
1704 &pcx,
1705 None,
1706 &[][..],
1707 &btreeset! { ConfigExperimental::SetupScripts },
1708 )
1709 .expect("config is valid");
1710 let profile = nextest_config
1711 .profile("default")
1712 .expect("default profile exists")
1713 .apply_build_platforms(&build_platforms());
1714
1715 let script_id = ScriptId::new("foo".into()).expect("foo is a valid script ID");
1716 let script = profile
1717 .script_config()
1718 .setup
1719 .get(&script_id)
1720 .expect("foo is defined as a setup script");
1721 assert_eq!(script.slow_timeout, expected);
1722 }
1723
1724 #[test_case(
1725 indoc! {r#"
1726 [scripts.setup.foo]
1727 command = "my-command"
1728
1729 [[profile.default.scripts]]
1730 setup = ["foo"]
1731 "#},
1732 "default",
1733 &[MietteJsonReport {
1734 message: "at least one of `platform` and `filter` must be specified".to_owned(),
1735 labels: vec![],
1736 }]
1737
1738 ; "neither platform nor filter specified"
1739 )]
1740 #[test_case(
1741 indoc! {r#"
1742 [scripts.setup.foo]
1743 command = "my-command"
1744
1745 [[profile.default.scripts]]
1746 platform = {}
1747 setup = ["foo"]
1748 "#},
1749 "default",
1750 &[MietteJsonReport {
1751 message: "at least one of `platform` and `filter` must be specified".to_owned(),
1752 labels: vec![],
1753 }]
1754
1755 ; "empty platform map"
1756 )]
1757 #[test_case(
1758 indoc! {r#"
1759 [scripts.setup.foo]
1760 command = "my-command"
1761
1762 [[profile.default.scripts]]
1763 platform = { host = 'cfg(target_os = "linux' }
1764 setup = ["foo"]
1765 "#},
1766 "default",
1767 &[MietteJsonReport {
1768 message: "error parsing cfg() expression".to_owned(),
1769 labels: vec![
1770 MietteJsonLabel { label: "expected one of `=`, `,`, `)` here".to_owned(), span: MietteJsonSpan { offset: 3, length: 1 } }
1771 ]
1772 }]
1773
1774 ; "invalid platform expression"
1775 )]
1776 #[test_case(
1777 indoc! {r#"
1778 [scripts.setup.foo]
1779 command = "my-command"
1780
1781 [[profile.ci.overrides]]
1782 filter = 'test(/foo)'
1783 setup = ["foo"]
1784 "#},
1785 "ci",
1786 &[MietteJsonReport {
1787 message: "expected close regex".to_owned(),
1788 labels: vec![
1789 MietteJsonLabel { label: "missing `/`".to_owned(), span: MietteJsonSpan { offset: 9, length: 0 } }
1790 ]
1791 }]
1792
1793 ; "invalid filterset"
1794 )]
1795 fn parse_scripts_invalid_compile(
1796 config_contents: &str,
1797 faulty_profile: &str,
1798 expected_reports: &[MietteJsonReport],
1799 ) {
1800 let workspace_dir = tempdir().unwrap();
1801
1802 let graph = temp_workspace(&workspace_dir, config_contents);
1803
1804 let pcx = ParseContext::new(&graph);
1805
1806 let error = NextestConfig::from_sources(
1807 graph.workspace().root(),
1808 &pcx,
1809 None,
1810 &[][..],
1811 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1812 )
1813 .expect_err("config is invalid");
1814 match error.kind() {
1815 ConfigParseErrorKind::CompileErrors(compile_errors) => {
1816 assert_eq!(
1817 compile_errors.len(),
1818 1,
1819 "exactly one override error must be produced"
1820 );
1821 let error = compile_errors.first().unwrap();
1822 assert_eq!(
1823 error.profile_name, faulty_profile,
1824 "compile error profile matches"
1825 );
1826 let handler = miette::JSONReportHandler::new();
1827 let reports = error
1828 .kind
1829 .reports()
1830 .map(|report| {
1831 let mut out = String::new();
1832 handler.render_report(&mut out, report.as_ref()).unwrap();
1833
1834 let json_report: MietteJsonReport = serde_json::from_str(&out)
1835 .unwrap_or_else(|err| {
1836 panic!(
1837 "failed to deserialize JSON message produced by miette: {err}"
1838 )
1839 });
1840 json_report
1841 })
1842 .collect::<Vec<_>>();
1843 assert_eq!(&reports, expected_reports, "reports match");
1844 }
1845 other => {
1846 panic!(
1847 "for config error {other:?}, expected ConfigParseErrorKind::CompiledDataParseError"
1848 );
1849 }
1850 }
1851 }
1852
1853 #[test_case(
1854 indoc! {r#"
1855 [scripts.setup.'@tool:foo:bar']
1856 command = "my-command"
1857
1858 [[profile.ci.overrides]]
1859 setup = ["@tool:foo:bar"]
1860 "#},
1861 &["@tool:foo:bar"]
1862
1863 ; "tool config in main program")]
1864 fn parse_scripts_invalid_defined(config_contents: &str, expected_invalid_scripts: &[&str]) {
1865 let workspace_dir = tempdir().unwrap();
1866
1867 let graph = temp_workspace(&workspace_dir, config_contents);
1868
1869 let pcx = ParseContext::new(&graph);
1870
1871 let error = NextestConfig::from_sources(
1872 graph.workspace().root(),
1873 &pcx,
1874 None,
1875 &[][..],
1876 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1877 )
1878 .expect_err("config is invalid");
1879 match error.kind() {
1880 ConfigParseErrorKind::InvalidConfigScriptsDefined(scripts) => {
1881 assert_eq!(
1882 scripts.len(),
1883 expected_invalid_scripts.len(),
1884 "correct number of scripts defined"
1885 );
1886 for (script, expected_script) in scripts.iter().zip(expected_invalid_scripts) {
1887 assert_eq!(script.as_str(), *expected_script, "script name matches");
1888 }
1889 }
1890 other => {
1891 panic!(
1892 "for config error {other:?}, expected ConfigParseErrorKind::InvalidConfigScriptsDefined"
1893 );
1894 }
1895 }
1896 }
1897
1898 #[test_case(
1899 indoc! {r#"
1900 [scripts.setup.'blarg']
1901 command = "my-command"
1902
1903 [[profile.ci.overrides]]
1904 setup = ["blarg"]
1905 "#},
1906 &["blarg"]
1907
1908 ; "non-tool config in tool")]
1909 fn parse_scripts_invalid_defined_by_tool(
1910 tool_config_contents: &str,
1911 expected_invalid_scripts: &[&str],
1912 ) {
1913 let workspace_dir = tempdir().unwrap();
1914 let graph = temp_workspace(&workspace_dir, "");
1915
1916 let tool_path = workspace_dir.child(".config/my-tool.toml");
1917 tool_path.write_str(tool_config_contents).unwrap();
1918 let tool_config_files = [ToolConfigFile {
1919 tool: tool_name("my-tool"),
1920 config_file: tool_path.to_path_buf(),
1921 }];
1922
1923 let pcx = ParseContext::new(&graph);
1924
1925 let error = NextestConfig::from_sources(
1926 graph.workspace().root(),
1927 &pcx,
1928 None,
1929 &tool_config_files,
1930 &btreeset! { ConfigExperimental::SetupScripts },
1931 )
1932 .expect_err("config is invalid");
1933 match error.kind() {
1934 ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool(scripts) => {
1935 assert_eq!(
1936 scripts.len(),
1937 expected_invalid_scripts.len(),
1938 "exactly one script must be defined"
1939 );
1940 for (script, expected_script) in scripts.iter().zip(expected_invalid_scripts) {
1941 assert_eq!(script.as_str(), *expected_script, "script name matches");
1942 }
1943 }
1944 other => {
1945 panic!(
1946 "for config error {other:?}, expected ConfigParseErrorKind::InvalidConfigScriptsDefinedByTool"
1947 );
1948 }
1949 }
1950 }
1951
1952 #[test_case(
1953 indoc! {r#"
1954 [scripts.setup.foo]
1955 command = 'echo foo'
1956
1957 [[profile.default.scripts]]
1958 platform = 'cfg(unix)'
1959 setup = ['bar']
1960
1961 [[profile.ci.scripts]]
1962 platform = 'cfg(unix)'
1963 setup = ['baz']
1964 "#},
1965 vec![
1966 ProfileUnknownScriptError {
1967 profile_name: "default".to_owned(),
1968 name: ScriptId::new("bar".into()).unwrap(),
1969 },
1970 ProfileUnknownScriptError {
1971 profile_name: "ci".to_owned(),
1972 name: ScriptId::new("baz".into()).unwrap(),
1973 },
1974 ],
1975 &["foo"]
1976
1977 ; "unknown scripts"
1978 )]
1979 fn parse_scripts_invalid_unknown(
1980 config_contents: &str,
1981 expected_errors: Vec<ProfileUnknownScriptError>,
1982 expected_known_scripts: &[&str],
1983 ) {
1984 let workspace_dir = tempdir().unwrap();
1985
1986 let graph = temp_workspace(&workspace_dir, config_contents);
1987
1988 let pcx = ParseContext::new(&graph);
1989
1990 let error = NextestConfig::from_sources(
1991 graph.workspace().root(),
1992 &pcx,
1993 None,
1994 &[][..],
1995 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
1996 )
1997 .expect_err("config is invalid");
1998 match error.kind() {
1999 ConfigParseErrorKind::ProfileScriptErrors {
2000 errors,
2001 known_scripts,
2002 } => {
2003 let ProfileScriptErrors {
2004 unknown_scripts,
2005 wrong_script_types,
2006 list_scripts_using_run_filters,
2007 } = &**errors;
2008 assert_eq!(wrong_script_types.len(), 0, "no wrong script types");
2009 assert_eq!(
2010 list_scripts_using_run_filters.len(),
2011 0,
2012 "no scripts using run filters in list phase"
2013 );
2014 assert_eq!(
2015 unknown_scripts.len(),
2016 expected_errors.len(),
2017 "correct number of errors"
2018 );
2019 for (error, expected_error) in unknown_scripts.iter().zip(expected_errors) {
2020 assert_eq!(error, &expected_error, "error matches");
2021 }
2022 assert_eq!(
2023 known_scripts.len(),
2024 expected_known_scripts.len(),
2025 "correct number of known scripts"
2026 );
2027 for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
2028 assert_eq!(
2029 script.as_str(),
2030 *expected_script,
2031 "known script name matches"
2032 );
2033 }
2034 }
2035 other => {
2036 panic!(
2037 "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
2038 );
2039 }
2040 }
2041 }
2042
2043 #[test_case(
2044 indoc! {r#"
2045 [scripts.setup.setup-script]
2046 command = 'echo setup'
2047
2048 [scripts.wrapper.wrapper-script]
2049 command = 'echo wrapper'
2050
2051 [[profile.default.scripts]]
2052 platform = 'cfg(unix)'
2053 setup = ['wrapper-script']
2054 list-wrapper = 'setup-script'
2055
2056 [[profile.ci.scripts]]
2057 platform = 'cfg(unix)'
2058 setup = 'wrapper-script'
2059 run-wrapper = 'setup-script'
2060 "#},
2061 vec![
2062 ProfileWrongConfigScriptTypeError {
2063 profile_name: "default".to_owned(),
2064 name: ScriptId::new("wrapper-script".into()).unwrap(),
2065 attempted: ProfileScriptType::Setup,
2066 actual: ScriptType::Wrapper,
2067 },
2068 ProfileWrongConfigScriptTypeError {
2069 profile_name: "default".to_owned(),
2070 name: ScriptId::new("setup-script".into()).unwrap(),
2071 attempted: ProfileScriptType::ListWrapper,
2072 actual: ScriptType::Setup,
2073 },
2074 ProfileWrongConfigScriptTypeError {
2075 profile_name: "ci".to_owned(),
2076 name: ScriptId::new("wrapper-script".into()).unwrap(),
2077 attempted: ProfileScriptType::Setup,
2078 actual: ScriptType::Wrapper,
2079 },
2080 ProfileWrongConfigScriptTypeError {
2081 profile_name: "ci".to_owned(),
2082 name: ScriptId::new("setup-script".into()).unwrap(),
2083 attempted: ProfileScriptType::RunWrapper,
2084 actual: ScriptType::Setup,
2085 },
2086 ],
2087 &["setup-script", "wrapper-script"]
2088
2089 ; "wrong script types"
2090 )]
2091 fn parse_scripts_invalid_wrong_type(
2092 config_contents: &str,
2093 expected_errors: Vec<ProfileWrongConfigScriptTypeError>,
2094 expected_known_scripts: &[&str],
2095 ) {
2096 let workspace_dir = tempdir().unwrap();
2097
2098 let graph = temp_workspace(&workspace_dir, config_contents);
2099
2100 let pcx = ParseContext::new(&graph);
2101
2102 let error = NextestConfig::from_sources(
2103 graph.workspace().root(),
2104 &pcx,
2105 None,
2106 &[][..],
2107 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
2108 )
2109 .expect_err("config is invalid");
2110 match error.kind() {
2111 ConfigParseErrorKind::ProfileScriptErrors {
2112 errors,
2113 known_scripts,
2114 } => {
2115 let ProfileScriptErrors {
2116 unknown_scripts,
2117 wrong_script_types,
2118 list_scripts_using_run_filters,
2119 } = &**errors;
2120 assert_eq!(unknown_scripts.len(), 0, "no unknown scripts");
2121 assert_eq!(
2122 list_scripts_using_run_filters.len(),
2123 0,
2124 "no scripts using run filters in list phase"
2125 );
2126 assert_eq!(
2127 wrong_script_types.len(),
2128 expected_errors.len(),
2129 "correct number of errors"
2130 );
2131 for (error, expected_error) in wrong_script_types.iter().zip(expected_errors) {
2132 assert_eq!(error, &expected_error, "error matches");
2133 }
2134 assert_eq!(
2135 known_scripts.len(),
2136 expected_known_scripts.len(),
2137 "correct number of known scripts"
2138 );
2139 for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
2140 assert_eq!(
2141 script.as_str(),
2142 *expected_script,
2143 "known script name matches"
2144 );
2145 }
2146 }
2147 other => {
2148 panic!(
2149 "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
2150 );
2151 }
2152 }
2153 }
2154
2155 #[test_case(
2156 indoc! {r#"
2157 [scripts.wrapper.list-script]
2158 command = 'echo list'
2159
2160 [[profile.default.scripts]]
2161 filter = 'test(hello)'
2162 list-wrapper = 'list-script'
2163
2164 [[profile.ci.scripts]]
2165 filter = 'test(world)'
2166 list-wrapper = 'list-script'
2167 "#},
2168 vec![
2169 ProfileListScriptUsesRunFiltersError {
2170 profile_name: "default".to_owned(),
2171 name: ScriptId::new("list-script".into()).unwrap(),
2172 script_type: ProfileScriptType::ListWrapper,
2173 filters: vec!["test(hello)".to_owned()].into_iter().collect(),
2174 },
2175 ProfileListScriptUsesRunFiltersError {
2176 profile_name: "ci".to_owned(),
2177 name: ScriptId::new("list-script".into()).unwrap(),
2178 script_type: ProfileScriptType::ListWrapper,
2179 filters: vec!["test(world)".to_owned()].into_iter().collect(),
2180 },
2181 ],
2182 &["list-script"]
2183
2184 ; "list scripts using run filters"
2185 )]
2186 fn parse_scripts_invalid_list_using_run_filters(
2187 config_contents: &str,
2188 expected_errors: Vec<ProfileListScriptUsesRunFiltersError>,
2189 expected_known_scripts: &[&str],
2190 ) {
2191 let workspace_dir = tempdir().unwrap();
2192
2193 let graph = temp_workspace(&workspace_dir, config_contents);
2194
2195 let pcx = ParseContext::new(&graph);
2196
2197 let error = NextestConfig::from_sources(
2198 graph.workspace().root(),
2199 &pcx,
2200 None,
2201 &[][..],
2202 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
2203 )
2204 .expect_err("config is invalid");
2205 match error.kind() {
2206 ConfigParseErrorKind::ProfileScriptErrors {
2207 errors,
2208 known_scripts,
2209 } => {
2210 let ProfileScriptErrors {
2211 unknown_scripts,
2212 wrong_script_types,
2213 list_scripts_using_run_filters,
2214 } = &**errors;
2215 assert_eq!(unknown_scripts.len(), 0, "no unknown scripts");
2216 assert_eq!(wrong_script_types.len(), 0, "no wrong script types");
2217 assert_eq!(
2218 list_scripts_using_run_filters.len(),
2219 expected_errors.len(),
2220 "correct number of errors"
2221 );
2222 for (error, expected_error) in
2223 list_scripts_using_run_filters.iter().zip(expected_errors)
2224 {
2225 assert_eq!(error, &expected_error, "error matches");
2226 }
2227 assert_eq!(
2228 known_scripts.len(),
2229 expected_known_scripts.len(),
2230 "correct number of known scripts"
2231 );
2232 for (script, expected_script) in known_scripts.iter().zip(expected_known_scripts) {
2233 assert_eq!(
2234 script.as_str(),
2235 *expected_script,
2236 "known script name matches"
2237 );
2238 }
2239 }
2240 other => {
2241 panic!(
2242 "for config error {other:?}, expected ConfigParseErrorKind::ProfileScriptErrors"
2243 );
2244 }
2245 }
2246 }
2247
2248 #[test]
2249 fn test_parse_scripts_empty_sections() {
2250 let config_contents = indoc! {r#"
2251 [scripts.setup.foo]
2252 command = 'echo foo'
2253
2254 [[profile.default.scripts]]
2255 platform = 'cfg(unix)'
2256
2257 [[profile.ci.scripts]]
2258 platform = 'cfg(unix)'
2259 "#};
2260
2261 let workspace_dir = tempdir().unwrap();
2262
2263 let graph = temp_workspace(&workspace_dir, config_contents);
2264
2265 let pcx = ParseContext::new(&graph);
2266
2267 let result = NextestConfig::from_sources(
2269 graph.workspace().root(),
2270 &pcx,
2271 None,
2272 &[][..],
2273 &btreeset! { ConfigExperimental::SetupScripts, ConfigExperimental::WrapperScripts },
2274 );
2275
2276 match result {
2277 Ok(_config) => {
2278 }
2281 Err(e) => {
2282 panic!("Config should be valid but got error: {e:?}");
2283 }
2284 }
2285 }
2286}