Skip to main content

rightkit_qa/
guard.rs

1//! The guard that keeps the in-app QA control surface out of builds that must not carry it.
2//!
3//! Two backends are understood: `control` (the in-app `rightkit-qa-driver` server,
4//! the default) and `wdio` (the legacy `tauri-plugin-wdio-webdriver`, kept so an
5//! app mid-migration still validates).
6//!
7//! The rule: the server is compiled only behind an explicit `qa-native` cargo feature.
8//! `qa-native` is off by default and only dev lanes enable it. A dev build may contain the
9//! server (it may use a release-derived profile, so `debug_assertions` is not required).
10//! A release or update build must not contain it; `right-release` refuses to seal one that does.
11use crate::util::{err, Result};
12use serde_json::Value;
13use std::collections::{BTreeMap, BTreeSet};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Backend {
17    Control,
18    Wdio,
19}
20
21impl Backend {
22    pub fn parse(s: &str) -> Option<Self> {
23        match s {
24            "control" => Some(Backend::Control),
25            "wdio" => Some(Backend::Wdio),
26            _ => None,
27        }
28    }
29    fn dependency(self) -> &'static str {
30        match self {
31            Backend::Control => "rightkit-qa-driver",
32            Backend::Wdio => "tauri-plugin-wdio-webdriver",
33        }
34    }
35    fn registration(self) -> &'static str {
36        match self {
37            Backend::Control => "rightkit_qa_driver::embedded::Control",
38            Backend::Wdio => "tauri_plugin_wdio_webdriver::init",
39        }
40    }
41    fn runtime_flag(self) -> &'static str {
42        match self {
43            Backend::Control => "RIGHTKIT_CONTROL_SERVICE",
44            Backend::Wdio => "RIGHTKIT_QA_NATIVE",
45        }
46    }
47}
48
49pub struct ContractInput<'a> {
50    pub cargo_toml: &'a str,
51    pub rust_source: &'a str,
52    pub package_scripts: &'a BTreeMap<String, String>,
53    pub backend: Backend,
54}
55
56/// Compiled-in gates that accept a registration: the feature alone (the current rule), or
57/// the older debug-only form. Matched against whitespace-free source text.
58const QA_NATIVE_GATES: [&str; 3] = [
59    r#"#[cfg(feature="qa-native")]"#,
60    r#"#[cfg(all(debug_assertions,feature="qa-native"))]"#,
61    r#"#[cfg(all(feature="qa-native",debug_assertions))]"#,
62];
63
64/// Feature items that switch on rightkit-tauri-shell developer inspection. None may be reachable from
65/// `default`: a release build must never contain it (`packages/release` refuses it as well).
66const DEV_INSPECT_FEATURES: [&str; 6] = [
67    "dev-inspect",
68    "rightkit-tauri-shell/dev-inspect",
69    "rightkit-tauri-shell?/dev-inspect",
70    // Apps still on the old published name (rightkit-shell) stay covered until they migrate.
71    "rightkit-shell/dev-inspect",
72    "rightkit-shell?/dev-inspect",
73    "dev-native",
74];
75
76fn compact(text: &str) -> String {
77    text.chars().filter(|c| !c.is_whitespace()).collect()
78}
79
80/// Text of `name = { ... }` (inline table) or the `[dependencies.name]` table.
81fn dependency_body(cargo: &str, name: &str) -> Option<String> {
82    let mut search = 0;
83    while let Some(i) = cargo[search..].find(name) {
84        let at = search + i;
85        search = at + name.len();
86        let prev = if at == 0 {
87            b' '
88        } else {
89            cargo.as_bytes()[at - 1]
90        };
91        let before_ok = !prev.is_ascii_alphanumeric() && prev != b'-' && prev != b'_';
92        let rest = cargo[at + name.len()..].trim_start();
93        if before_ok && rest.starts_with('=') {
94            let rest = rest[1..].trim_start();
95            if let Some(body) = rest.strip_prefix('{') {
96                return body.find('}').map(|e| body[..e].to_string());
97            }
98            return Some(String::new());
99        }
100    }
101    cargo.find(&format!("[dependencies.{name}]")).map(|i| {
102        let tail = &cargo[i..];
103        let end = tail[1..].find("\n[").map(|e| e + 1).unwrap_or(tail.len());
104        tail[..end].to_string()
105    })
106}
107
108/// Text of the `[features]` table, up to the next table header.
109fn features_table(cargo: &str) -> &str {
110    let Some(start) = cargo.find("[features]") else {
111        return "";
112    };
113    let body = &cargo[start + "[features]".len()..];
114    let end = body.find("\n[").unwrap_or(body.len());
115    &body[..end]
116}
117
118/// Every `name = [ ... ]` entry of the `[features]` table, as (name, list body). A list may
119/// continue over several lines.
120fn feature_entries(cargo: &str) -> Vec<(String, String)> {
121    let table = features_table(cargo);
122    let mut out = Vec::new();
123    let mut pos = 0;
124    while pos < table.len() {
125        let line_end = table[pos..].find('\n').map_or(table.len(), |i| pos + i + 1);
126        let trimmed = table[pos..line_end].trim();
127        if let Some((key, value)) = trimmed.split_once('=') {
128            let key = key.trim();
129            if let Some(list) = value.trim_start().strip_prefix('[') {
130                if !key.is_empty() && !key.starts_with('#') {
131                    let mut body = list.to_string();
132                    let mut end = line_end;
133                    while !body.contains(']') && end < table.len() {
134                        let next = table[end..].find('\n').map_or(table.len(), |i| end + i + 1);
135                        body.push_str(&table[end..next]);
136                        end = next;
137                    }
138                    let close = body.find(']').unwrap_or(body.len());
139                    out.push((key.to_string(), body[..close].to_string()));
140                    pos = end;
141                    continue;
142                }
143            }
144        }
145        pos = line_end;
146    }
147    out
148}
149
150/// Items of a feature list body such as `"dep:rightkit-qa-driver", "rightkit-qa-driver/qa-control"`.
151fn list_items(list: &str) -> Vec<String> {
152    list.split(',')
153        .map(|s| s.trim().trim_matches('"').trim().to_string())
154        .filter(|s| !s.is_empty())
155        .collect()
156}
157
158/// Whether `default`, directly or through other features, enables `target`.
159fn default_reaches(entries: &[(String, String)], target: &str) -> bool {
160    let mut stack: Vec<String> = entries
161        .iter()
162        .filter(|(k, _)| k.as_str() == "default")
163        .flat_map(|(_, v)| list_items(v))
164        .collect();
165    let mut seen = BTreeSet::new();
166    while let Some(item) = stack.pop() {
167        if item == target {
168            return true;
169        }
170        if !seen.insert(item.clone()) {
171            continue;
172        }
173        if let Some((_, list)) = entries.iter().find(|(k, _)| k.as_str() == item.as_str()) {
174            stack.extend(list_items(list));
175        }
176    }
177    false
178}
179
180/// False for a `use` line or a comment line. Those name the registration path but do not
181/// register anything, so they need no gate.
182fn is_code_at(src: &str, at: usize) -> bool {
183    let line_start = src[..at].rfind('\n').map_or(0, |p| p + 1);
184    let line = src[line_start..].lines().next().unwrap_or("").trim_start();
185    !(line.starts_with("use ")
186        || line.starts_with("pub use ")
187        || line.starts_with("pub(crate) use ")
188        || line.starts_with("//"))
189}
190
191/// Whether the code at `at` sits under an accepted qa-native gate. The gate may sit on the
192/// statement itself, or on an enclosing function or block, so the walk moves outward
193/// through `{` boundaries and stops at the first `;` or `}`.
194fn is_gated(src: &str, at: usize) -> bool {
195    let mut end = at;
196    for _ in 0..8 {
197        let found = src[..end].rfind([';', '{', '}']);
198        let head_start = found.map_or(0, |p| p + 1);
199        let head = compact(&src[head_start..end]);
200        if QA_NATIVE_GATES.iter().any(|gate| head.contains(*gate)) {
201            return true;
202        }
203        match found {
204            Some(p) if src.as_bytes()[p] == b'{' => end = p,
205            _ => return false,
206        }
207    }
208    false
209}
210
211pub fn validate(input: &ContractInput) -> Vec<String> {
212    let mut issues = vec![];
213    let dep = input.backend.dependency();
214    let control = input.backend == Backend::Control;
215    let dep_body = dependency_body(input.cargo_toml, dep).map(|b| compact(&b));
216    let dep_enables_qa_control = dep_body
217        .as_deref()
218        .is_some_and(|b| b.contains("\"qa-control\""));
219    match &dep_body {
220        None => issues.push(format!(
221            "Cargo.toml must declare {dep} under [dependencies] as an optional dependency: \
222             `{dep} = {{ version = \"...\", optional = true, default-features = false }}`"
223        )),
224        Some(body) => {
225            if !body.contains("optional=true") {
226                issues.push(format!(
227                    "{dep} must be optional: declare it with `optional = true` so that only \
228                     the qa-native feature compiles the QA server into the app"
229                ));
230                if control && dep_enables_qa_control {
231                    issues.push(format!(
232                        "{dep} enables the qa-control feature without optional = true, so the \
233                         QA server compiles into every build: make the dependency optional and \
234                         enable qa-control only from qa-native"
235                    ));
236                }
237            }
238        }
239    }
240
241    let entries = feature_entries(input.cargo_toml);
242    let dep_feature = format!("dep:{dep}");
243    let qa_control_feature = format!("{dep}/qa-control");
244    match entries.iter().find(|(k, _)| k.as_str() == "qa-native") {
245        None => issues.push(
246            "Cargo.toml must define the qa-native feature under [features], for example \
247             `qa-native = [\"dep:rightkit-qa-driver\", \"rightkit-qa-driver/qa-control\"]`"
248                .into(),
249        ),
250        Some((_, list)) => {
251            let items = list_items(list);
252            if !items.iter().any(|i| i.as_str() == dep_feature.as_str()) {
253                issues.push(format!("qa-native must enable {dep_feature}"));
254            }
255            let enables_control_qa = items
256                .iter()
257                .any(|i| i.as_str() == qa_control_feature.as_str());
258            if control && !enables_control_qa && !dep_enables_qa_control {
259                issues.push(
260                    "qa-native must enable rightkit-qa-driver/qa-control (add it to the qa-native \
261                     list, or list qa-control in the rightkit-qa-driver dependency's features); \
262                     without it the embedded server is not compiled"
263                        .into(),
264                );
265            }
266        }
267    }
268    if default_reaches(&entries, "qa-native") {
269        issues.push(
270            "qa-native must not be enabled by default: remove it from `default` (directly or \
271             through another default feature) so that only `--features qa-native` builds \
272             contain the QA server"
273                .into(),
274        );
275    }
276    for target in DEV_INSPECT_FEATURES {
277        if default_reaches(&entries, target) {
278            issues.push(format!(
279                "{target} must not be enabled by default: it compiles developer inspection \
280                 (rightkit-tauri-shell dev-inspect) into the app, and right-release refuses to seal \
281                 or upload such a build. Remove it from `default` (directly or through another \
282                 default feature) so that only an explicit dev build contains it"
283            ));
284        }
285    }
286    // The crate was renamed from rightkit-shell; apps still on the old published name stay covered.
287    for shell_crate in ["rightkit-tauri-shell", "rightkit-shell"] {
288        if let Some(body) = dependency_body(input.cargo_toml, shell_crate).map(|b| compact(&b)) {
289            if body.contains("\"dev-inspect\"") && !body.contains("optional=true") {
290                issues.push(format!(
291                    "{shell_crate} lists the dev-inspect feature without optional = true, so \
292                     developer inspection compiles into every build: enable it only from an \
293                     optional dev feature such as `dev-native = [\"{shell_crate}/dev-inspect\"]`"
294                ));
295            }
296        }
297    }
298    for (name, list) in &entries {
299        if name.as_str() == "qa-native" {
300            continue;
301        }
302        let items = list_items(list);
303        if items.iter().any(|i| {
304            i.as_str() == dep_feature.as_str() || i.as_str() == qa_control_feature.as_str()
305        }) {
306            issues.push(format!(
307                "feature {name} enables {dep}; only qa-native may enable the QA server \
308                 dependency, so remove it from {name}"
309            ));
310        }
311    }
312
313    let registration = input.backend.registration();
314    let sites: Vec<usize> = input
315        .rust_source
316        .match_indices(registration)
317        .map(|(at, _)| at)
318        .filter(|&at| is_code_at(input.rust_source, at))
319        .collect();
320    if sites.is_empty() {
321        issues.push(format!(
322            "the app must register {dep} in its Rust source: `{registration}` inside \
323             `#[cfg(feature = \"qa-native\")]`"
324        ));
325    }
326    let ungated: Vec<usize> = sites
327        .iter()
328        .copied()
329        .filter(|&at| !is_gated(input.rust_source, at))
330        .collect();
331    if let Some(&first) = ungated.first() {
332        let line = input.rust_source[..first].matches('\n').count() + 1;
333        issues.push(format!(
334            "{dep} registration `{registration}` is unconditional ({} place(s), first at line \
335             {line} of the Rust sources): gate each registration statement, or the function or \
336             block that contains it, with `#[cfg(feature = \"qa-native\")]` so that a build \
337             without the qa-native feature does not contain the QA server",
338            ungated.len()
339        ));
340    }
341
342    let rust_compact = compact(input.rust_source);
343    let runtime_gated = rust_compact.contains(input.backend.runtime_flag())
344        || (control && rust_compact.contains("build_if_enabled"));
345    if !runtime_gated {
346        issues.push(format!(
347            "registration must also require the {} runtime flag",
348            input.backend.runtime_flag()
349        ));
350    }
351
352    for (name, command) in input.package_scripts {
353        if command.contains("dev-inspect") || command.contains("dev-native") {
354            let lower = name.to_lowercase();
355            let dev_lane = ["qa", "test", "dev"]
356                .iter()
357                .any(|p| name == p || name.starts_with(&format!("{p}:")));
358            if !dev_lane
359                && ["release", "publish", "dmg", "installer", "package"]
360                    .iter()
361                    .any(|w| lower.contains(w))
362            {
363                issues.push(format!(
364                    "package script {name} must not enable dev-inspect or dev-native; only \
365                     qa, test or dev scripts may"
366                ));
367            }
368        }
369        if !command.contains("qa-native") {
370            continue;
371        }
372        if ["qa", "test", "dev"]
373            .iter()
374            .any(|p| name == p || name.starts_with(&format!("{p}:")))
375        {
376            continue;
377        }
378        let lower = name.to_lowercase();
379        if ["release", "publish", "build", "dmg", "installer", "package"]
380            .iter()
381            .any(|w| lower.contains(w))
382            || command.contains("tauri build")
383        {
384            issues.push(format!(
385                "package script {name} must not enable qa-native; only qa, test or dev scripts may"
386            ));
387        }
388    }
389    issues
390}
391
392pub fn assert_contract(input: &ContractInput) -> Result<()> {
393    let issues = validate(input);
394    if issues.is_empty() {
395        Ok(())
396    } else {
397        err(format!(
398            "native QA contract failed:\n- {}",
399            issues.join("\n- ")
400        ))
401    }
402}
403
404pub fn scripts_from_package_json(text: &str) -> Result<BTreeMap<String, String>> {
405    let v: Value = serde_json::from_str(text)?;
406    Ok(v.get("scripts")
407        .and_then(Value::as_object)
408        .map(|m| {
409            m.iter()
410                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
411                .collect()
412        })
413        .unwrap_or_default())
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    /// Shape in the README: the server is pulled in only by `qa-native`.
421    const CARGO: &str = r##"
422[features]
423default = []
424qa-native = ["dep:rightkit-qa-driver", "rightkit-qa-driver/qa-control"]
425[dependencies]
426rightkit-qa-driver = { version = "0.3", optional = true, default-features = false }
427"##;
428
429    /// The alternative shape: qa-control listed in the optional dependency's own features.
430    const CARGO_DEP_FEATURES: &str = r##"
431[features]
432default = []
433qa-native = ["dep:rightkit-qa-driver"]
434[dependencies]
435rightkit-qa-driver = { version = "0.3", optional = true, default-features = false, features = ["qa-control"] }
436"##;
437
438    /// Registration gated by the feature alone; the registration also appears in a `use` line.
439    const RUST_GATED: &str = r##"
440use rightkit_qa_driver::embedded::Control;
441fn run() {
442    #[cfg(feature = "qa-native")]
443    if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
444}"##;
445
446    /// The older debug-only form, with its compile_error. Still accepted.
447    const RUST_DEBUG_ONLY: &str = r##"
448#[cfg(all(not(debug_assertions), feature = "qa-native"))]
449compile_error!("qa-native is debug only");
450fn run() {
451    #[cfg(all(debug_assertions, feature = "qa-native"))]
452    if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
453}"##;
454
455    /// The gate sits on the function that holds the registration.
456    const RUST_FN_GATED: &str = r##"
457#[cfg(feature = "qa-native")]
458fn register(b: Builder) -> Builder {
459    if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b.plugin(p) } else { b }
460}"##;
461
462    const RUST_UNGATED: &str = r##"
463use rightkit_qa_driver::embedded::Control;
464fn run() {
465    if let Some(p) = rightkit_qa_driver::embedded::Control::<tauri::Wry>::new().build_if_enabled() { b = b.plugin(p); }
466}"##;
467
468    fn check(cargo: &str, rust: &str, scripts: &[(&str, &str)], backend: Backend) -> Vec<String> {
469        let scripts: BTreeMap<String, String> = scripts
470            .iter()
471            .map(|(k, v)| (k.to_string(), v.to_string()))
472            .collect();
473        validate(&ContractInput {
474            cargo_toml: cargo,
475            rust_source: rust,
476            package_scripts: &scripts,
477            backend,
478        })
479    }
480
481    fn control(cargo: &str, rust: &str, scripts: &[(&str, &str)]) -> Vec<String> {
482        check(cargo, rust, scripts, Backend::Control)
483    }
484
485    fn any_contains(issues: &[String], needle: &str) -> bool {
486        issues.iter().any(|s| s.contains(needle))
487    }
488
489    #[test]
490    fn accepts_qa_native_gate_without_debug_assertions() {
491        let issues = control(
492            CARGO,
493            RUST_GATED,
494            &[("qa", "cargo run --features qa-native")],
495        );
496        assert_eq!(issues, Vec::<String>::new());
497    }
498
499    #[test]
500    fn accepts_older_debug_only_form() {
501        let issues = control(
502            CARGO,
503            RUST_DEBUG_ONLY,
504            &[("qa", "cargo run --features qa-native")],
505        );
506        assert_eq!(issues, Vec::<String>::new());
507    }
508
509    #[test]
510    fn accepts_gate_on_enclosing_function() {
511        assert_eq!(control(CARGO, RUST_FN_GATED, &[]), Vec::<String>::new());
512    }
513
514    #[test]
515    fn accepts_qa_control_listed_in_dependency_features() {
516        assert_eq!(
517            control(CARGO_DEP_FEATURES, RUST_GATED, &[]),
518            Vec::<String>::new()
519        );
520    }
521
522    #[test]
523    fn accepts_wdio_backend_gated_by_qa_native() {
524        let cargo = r##"
525[features]
526default = []
527qa-native = ["dep:tauri-plugin-wdio-webdriver"]
528[dependencies]
529tauri-plugin-wdio-webdriver = { version = "0.1", optional = true }
530"##;
531        let rust = r##"
532const FLAG: &str = "RIGHTKIT_QA_NATIVE";
533#[cfg(feature = "qa-native")]
534fn run() { b = b.plugin(tauri_plugin_wdio_webdriver::init()); }
535"##;
536        let issues = check(cargo, rust, &[], Backend::Wdio);
537        assert_eq!(issues, Vec::<String>::new());
538    }
539
540    /// The optional dev feature that selects rightkit-tauri-shell developer inspection.
541    const CARGO_DEV_INSPECT: &str = r##"
542[features]
543default = []
544qa-native = ["dep:rightkit-qa-driver", "rightkit-qa-driver/qa-control"]
545dev-native = ["rightkit-tauri-shell/dev-inspect"]
546[dependencies]
547rightkit-qa-driver = { version = "0.3", optional = true, default-features = false }
548rightkit-tauri-shell = { version = "0.4", default-features = false }
549"##;
550
551    #[test]
552    fn accepts_dev_inspect_behind_an_off_by_default_dev_feature() {
553        assert_eq!(
554            control(
555                CARGO_DEV_INSPECT,
556                RUST_GATED,
557                &[("dev", "cargo run --features dev-native")]
558            ),
559            Vec::<String>::new()
560        );
561    }
562
563    #[test]
564    fn rejects_dev_inspect_in_default_features() {
565        for default in [
566            "default = [\"dev-native\"]",
567            "default = [\"rightkit-tauri-shell/dev-inspect\"]",
568            "default = [\"dev-inspect\"]",
569            "default = [\"shell\"]\nshell = [\"dev-native\"]",
570        ] {
571            let cargo = CARGO_DEV_INSPECT.replace("default = []", default);
572            let issues = control(&cargo, RUST_GATED, &[]);
573            assert!(
574                any_contains(&issues, "must not be enabled by default"),
575                "{default}: {issues:?}"
576            );
577        }
578    }
579
580    #[test]
581    fn rejects_non_optional_dev_inspect_dependency_and_release_scripts() {
582        let cargo = CARGO_DEV_INSPECT.replace(
583            "rightkit-tauri-shell = { version = \"0.4\", default-features = false }",
584            "rightkit-tauri-shell = { version = \"0.4\", features = [\"dev-inspect\"] }",
585        );
586        let issues = control(&cargo, RUST_GATED, &[]);
587        assert!(
588            any_contains(&issues, "without optional = true"),
589            "{issues:?}"
590        );
591        let issues = control(
592            CARGO_DEV_INSPECT,
593            RUST_GATED,
594            &[("build:release", "tauri build --features dev-native")],
595        );
596        assert!(
597            any_contains(
598                &issues,
599                "package script build:release must not enable dev-inspect"
600            ),
601            "{issues:?}"
602        );
603    }
604
605    #[test]
606    fn rejects_unconditional_registration() {
607        let issues = control(CARGO, RUST_UNGATED, &[]);
608        assert!(any_contains(&issues, "is unconditional"), "{issues:?}");
609    }
610
611    #[test]
612    fn rejects_negated_gate() {
613        let negated = RUST_GATED.replace(
614            "#[cfg(feature = \"qa-native\")]",
615            "#[cfg(not(feature = \"qa-native\"))]",
616        );
617        let issues = control(CARGO, &negated, &[]);
618        assert!(any_contains(&issues, "is unconditional"), "{issues:?}");
619    }
620
621    #[test]
622    fn rejects_release_scripts_and_non_optional_dependency() {
623        let cargo = CARGO.replace("optional = true, ", "");
624        let issues = control(
625            &cargo,
626            RUST_GATED,
627            &[("release", "tauri build --features qa-native")],
628        );
629        assert!(any_contains(&issues, "must be optional"), "{issues:?}");
630        assert!(
631            any_contains(&issues, "package script release"),
632            "{issues:?}"
633        );
634    }
635
636    #[test]
637    fn rejects_non_optional_dependency_that_enables_qa_control() {
638        let cargo = CARGO_DEP_FEATURES.replace("optional = true, ", "");
639        let issues = control(&cargo, RUST_GATED, &[]);
640        assert!(any_contains(&issues, "must be optional"), "{issues:?}");
641        assert!(
642            any_contains(
643                &issues,
644                "enables the qa-control feature without optional = true"
645            ),
646            "{issues:?}"
647        );
648    }
649
650    #[test]
651    fn rejects_qa_native_in_default_features() {
652        let cargo = CARGO.replace("default = []", "default = [\"qa-native\"]");
653        let issues = control(&cargo, RUST_GATED, &[]);
654        assert!(
655            any_contains(&issues, "must not be enabled by default"),
656            "{issues:?}"
657        );
658    }
659
660    #[test]
661    fn rejects_qa_native_reached_through_a_default_feature() {
662        let cargo = CARGO.replace(
663            "default = []",
664            "default = [\"full\"]\nfull = [\"qa-native\"]",
665        );
666        let issues = control(&cargo, RUST_GATED, &[]);
667        assert!(
668            any_contains(&issues, "must not be enabled by default"),
669            "{issues:?}"
670        );
671    }
672
673    #[test]
674    fn rejects_qa_native_without_qa_control() {
675        let cargo = CARGO.replace(", \"rightkit-qa-driver/qa-control\"", "");
676        let issues = control(&cargo, RUST_GATED, &[]);
677        assert!(
678            any_contains(&issues, "rightkit-qa-driver/qa-control"),
679            "{issues:?}"
680        );
681    }
682
683    #[test]
684    fn rejects_other_feature_that_enables_the_dependency() {
685        let cargo = CARGO.replace(
686            "[dependencies]",
687            "browser-ui = [\"dep:rightkit-qa-driver\"]\n[dependencies]",
688        );
689        let issues = control(&cargo, RUST_GATED, &[]);
690        assert!(
691            any_contains(&issues, "feature browser-ui enables"),
692            "{issues:?}"
693        );
694    }
695
696    #[test]
697    fn rejects_missing_qa_native_feature() {
698        let cargo = CARGO.replace(
699            "qa-native = [\"dep:rightkit-qa-driver\", \"rightkit-qa-driver/qa-control\"]\n",
700            "",
701        );
702        let issues = control(&cargo, RUST_GATED, &[]);
703        assert!(
704            any_contains(&issues, "must define the qa-native feature"),
705            "{issues:?}"
706        );
707    }
708
709    #[test]
710    fn rejects_missing_registration() {
711        let issues = control(CARGO, "fn main(){}", &[]);
712        assert!(any_contains(&issues, "must register"), "{issues:?}");
713    }
714}