Skip to main content

smix_fixture/
lib.rs

1//! smix-fixture — fixture chip registry.
2//!
3//! # Purpose
4//!
5//! The `- fixture: <id>` yaml verb (in smix-adapter-maestro) looks up
6//! `<id>` in a JSON registry to find:
7//!
8//! - `testID` — the a11y id of the chip in the QA overlay to tap
9//! - `signal` — the log line regex the chip emits when its seed
10//!   operation completes
11//! - `timeoutMs` — how long to wait for the signal
12//!
13//! smix reads the registry once per run from a path resolved via
14//! `.smix/config.json` `fixturesRegistry` field.
15//!
16//! # Format
17//!
18//! JSON:
19//!
20//! ```json
21//! {
22//!   "version": 1,
23//!   "fixtures": {
24//!     "prime-search-history": {
25//!       "testID": "qa-chip-prime-search-history",
26//!       "signal": {
27//!         "regex": "\\[fixture\\] prime-search-history: seeded (\\d+) rows",
28//!         "level": "log"
29//!       },
30//!       "timeoutMs": 8000
31//!     }
32//!   }
33//! }
34//! ```
35
36use std::collections::BTreeMap;
37use std::path::Path;
38
39use serde::{Deserialize, Serialize};
40use thiserror::Error;
41
42/// One fixture chip declaration.
43#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct FixtureDecl {
45    /// a11y id of the chip to tap in the QA overlay. Serialized as
46    /// `testID` (all-caps ID) to match the consumer-side registry
47    /// convention.
48    #[serde(rename = "testID")]
49    pub test_id: String,
50    /// Log signal the chip emits when seeding completes.
51    pub signal: SignalMatcher,
52    /// Timeout in milliseconds. Runtime awaits `signal` up to this.
53    #[serde(rename = "timeoutMs")]
54    pub timeout_ms: u64,
55}
56
57/// Signal matcher (mirrors `smix_metro_log::SignalMatcher` at the wire
58/// level).
59#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
60pub struct SignalMatcher {
61    pub regex: String,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub level: Option<String>,
64}
65
66/// Registry file shape.
67#[derive(Debug, Deserialize)]
68struct RegistryFile {
69    #[serde(default)]
70    #[allow(dead_code)]
71    version: u32,
72    fixtures: BTreeMap<String, FixtureDecl>,
73}
74
75/// Loaded registry.
76#[derive(Clone, Debug)]
77pub struct FixtureRegistry {
78    fixtures: BTreeMap<String, FixtureDecl>,
79}
80
81#[derive(Debug, Error)]
82pub enum RegistryError {
83    #[error("read {path}: {source}")]
84    Io {
85        path: String,
86        #[source]
87        source: std::io::Error,
88    },
89    #[error("parse {path}: {detail}")]
90    Malformed { path: String, detail: String },
91    #[error("fixture not found: {id} (registered: {known:?})")]
92    NotFound { id: String, known: Vec<String> },
93}
94
95impl FixtureRegistry {
96    /// Load a registry file. Auto-detects format by extension:
97    /// - `.json` → parsed as JSON
98    /// - `.ts` / `.tsx` → lightweight TypeScript
99    ///   `export const FIXTURES = { ... }` extractor (no swc / oxc
100    ///   dep — handles the common documented shape)
101    /// - anything else → try JSON first, then TS extractor as fallback
102    pub fn load(path: &Path) -> Result<Self, RegistryError> {
103        let text = std::fs::read_to_string(path).map_err(|source| RegistryError::Io {
104            path: path.display().to_string(),
105            source,
106        })?;
107        let ext = path
108            .extension()
109            .and_then(|s| s.to_str())
110            .map(|s| s.to_ascii_lowercase());
111        match ext.as_deref() {
112            Some("ts") | Some("tsx") => Self::load_from_ts_source(&text, path),
113            Some("json") => Self::load_from_json(&text, path),
114            _ => {
115                // Try JSON first; fall back to TS extractor.
116                Self::load_from_json(&text, path)
117                    .or_else(|_| Self::load_from_ts_source(&text, path))
118            }
119        }
120    }
121
122    fn load_from_json(text: &str, path: &Path) -> Result<Self, RegistryError> {
123        let file: RegistryFile =
124            serde_json::from_str(text).map_err(|e| RegistryError::Malformed {
125                path: path.display().to_string(),
126                detail: e.to_string(),
127            })?;
128        Ok(Self {
129            fixtures: file.fixtures,
130        })
131    }
132
133    /// Lightweight TS `export const FIXTURES = {...}` extractor.
134    /// Matches the documented shape:
135    /// ```ts
136    /// export const FIXTURES: Record<FixtureId, FixtureDecl> = {
137    ///   'prime-search-history': {
138    ///     testID: 'qa-chip-prime-search-history',
139    ///     signal: { level: 'log', regex: /\[fixture\] .../ },
140    ///     timeoutMs: 8000,
141    ///   },
142    ///   // ...
143    /// }
144    /// ```
145    ///
146    /// Strategy: find `= {` after `FIXTURES`, extract the balanced
147    /// brace block, then normalize TS-isms to JSON5-ish before
148    /// json parsing:
149    /// - Convert single quotes to double quotes for keys/strings
150    /// - Convert unquoted keys to quoted keys
151    /// - Convert regex literals `/pattern/` to string `"pattern"`
152    /// - Strip trailing commas
153    /// - Strip line + block comments
154    ///
155    /// This handles the common case without pulling in a full TS
156    /// parser. Consumers with exotic TS syntax should codegen JSON
157    /// via the documented `gen-json.mjs` script.
158    fn load_from_ts_source(text: &str, path: &Path) -> Result<Self, RegistryError> {
159        // 1. Strip comments (line + block).
160        let cleaned = strip_ts_comments(text);
161        // 2. Find `FIXTURES` binding.
162        let start = cleaned
163            .find("FIXTURES")
164            .ok_or_else(|| RegistryError::Malformed {
165                path: path.display().to_string(),
166                detail: "no `FIXTURES` binding found; use JSON registry or codegen".into(),
167            })?;
168        let after = &cleaned[start..];
169        // 3. Find first `{` after `= `.
170        let eq = after.find('=').ok_or_else(|| RegistryError::Malformed {
171            path: path.display().to_string(),
172            detail: "no `=` after FIXTURES binding".into(),
173        })?;
174        let brace_start = after[eq..]
175            .find('{')
176            .ok_or_else(|| RegistryError::Malformed {
177                path: path.display().to_string(),
178                detail: "no `{` in FIXTURES value".into(),
179            })?
180            + eq;
181        let object_lit = extract_balanced_braces(&after[brace_start..]).ok_or_else(|| {
182            RegistryError::Malformed {
183                path: path.display().to_string(),
184                detail: "unbalanced braces in FIXTURES value".into(),
185            }
186        })?;
187        // 4. TS → JSON-ish massage.
188        let json_ish = normalize_ts_object_to_json(&object_lit);
189        // 5. Parse. Expected result: { "prime-search-history": {...}, ... }
190        let fixtures: BTreeMap<String, FixtureDecl> =
191            serde_json::from_str(&json_ish).map_err(|e| RegistryError::Malformed {
192                path: path.display().to_string(),
193                detail: format!("TS-to-JSON parse: {e}; normalized text: {json_ish}"),
194            })?;
195        Ok(Self { fixtures })
196    }
197
198    pub fn get(&self, id: &str) -> Option<&FixtureDecl> {
199        self.fixtures.get(id)
200    }
201
202    pub fn require(&self, id: &str) -> Result<&FixtureDecl, RegistryError> {
203        self.get(id).ok_or_else(|| RegistryError::NotFound {
204            id: id.to_string(),
205            known: self.fixtures.keys().cloned().collect(),
206        })
207    }
208
209    pub fn ids(&self) -> impl Iterator<Item = &String> {
210        self.fixtures.keys()
211    }
212
213    /// Construct from an in-memory map. Test / library use — the CLI
214    /// always goes through [`Self::load`].
215    pub fn from_map(fixtures: BTreeMap<String, FixtureDecl>) -> Self {
216        Self { fixtures }
217    }
218}
219
220/// Strip line + block comments from TS source.
221fn strip_ts_comments(text: &str) -> String {
222    let mut out = String::with_capacity(text.len());
223    let bytes = text.as_bytes();
224    let mut i = 0;
225    let mut in_string: Option<u8> = None;
226    while i < bytes.len() {
227        let c = bytes[i];
228        // Track string state — comments inside strings are content.
229        if let Some(q) = in_string {
230            out.push(c as char);
231            if c == q && (i == 0 || bytes[i - 1] != b'\\') {
232                in_string = None;
233            }
234            i += 1;
235            continue;
236        }
237        if c == b'"' || c == b'\'' || c == b'`' {
238            in_string = Some(c);
239            out.push(c as char);
240            i += 1;
241            continue;
242        }
243        // Line comment.
244        if c == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
245            while i < bytes.len() && bytes[i] != b'\n' {
246                i += 1;
247            }
248            continue;
249        }
250        // Block comment.
251        if c == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
252            i += 2;
253            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
254                i += 1;
255            }
256            i += 2;
257            continue;
258        }
259        out.push(c as char);
260        i += 1;
261    }
262    out
263}
264
265/// Extract the substring from `s[0..]` that balances the opening `{`.
266/// Returns None if braces don't balance.
267fn extract_balanced_braces(s: &str) -> Option<String> {
268    let bytes = s.as_bytes();
269    let mut depth = 0i32;
270    let mut in_string: Option<u8> = None;
271    let mut end = 0;
272    for (i, &c) in bytes.iter().enumerate() {
273        if let Some(q) = in_string {
274            if c == q && (i == 0 || bytes[i - 1] != b'\\') {
275                in_string = None;
276            }
277            continue;
278        }
279        if c == b'"' || c == b'\'' || c == b'`' {
280            in_string = Some(c);
281            continue;
282        }
283        if c == b'{' {
284            depth += 1;
285        } else if c == b'}' {
286            depth -= 1;
287            if depth == 0 {
288                end = i + 1;
289                break;
290            }
291        }
292    }
293    if end == 0 {
294        None
295    } else {
296        Some(s[..end].to_string())
297    }
298}
299
300/// Normalize a TS object literal to JSON.
301///
302/// Transforms applied:
303/// - Single-quoted strings → double-quoted
304/// - Unquoted object keys → double-quoted
305/// - Regex literals `/pattern/flags` → `"pattern"` (keeps the source
306///   pattern; flags dropped since Rust regex has different flag
307///   syntax anyway)
308/// - Trailing commas removed
309fn normalize_ts_object_to_json(s: &str) -> String {
310    let mut out = String::with_capacity(s.len());
311    let bytes = s.as_bytes();
312    let mut i = 0;
313    // Track "expecting a key" state — true after `{` or `,` (ignoring
314    // whitespace / newlines).
315    let mut expect_key = false;
316    while i < bytes.len() {
317        let c = bytes[i];
318        // Skip whitespace but track for state.
319        if c.is_ascii_whitespace() {
320            out.push(c as char);
321            i += 1;
322            continue;
323        }
324        if c == b'{' || c == b',' {
325            out.push(c as char);
326            expect_key = c == b'{';
327            i += 1;
328            // After `,`, next non-ws token could be a key or `}`.
329            // Peek: if `}`, we have a trailing comma — strip it.
330            let mut j = i;
331            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
332                j += 1;
333            }
334            if c == b',' && j < bytes.len() && bytes[j] == b'}' {
335                // Trailing comma — remove the just-written `,`.
336                out.pop();
337                i = j;
338                continue;
339            }
340            if c == b',' {
341                expect_key = true;
342            }
343            continue;
344        }
345        // Single-quoted string → double-quoted.
346        if c == b'\'' {
347            out.push('"');
348            i += 1;
349            while i < bytes.len() && bytes[i] != b'\'' {
350                if bytes[i] == b'"' {
351                    out.push_str("\\\"");
352                } else {
353                    out.push(bytes[i] as char);
354                }
355                i += 1;
356            }
357            out.push('"');
358            i += 1; // closing quote
359            expect_key = false;
360            continue;
361        }
362        // Regex literal /pattern/flags. Only recognize when in value
363        // position (not expecting a key).
364        if c == b'/'
365            && !expect_key
366            && i + 1 < bytes.len()
367            && bytes[i + 1] != b'/'
368            && bytes[i + 1] != b'*'
369        {
370            i += 1;
371            let mut pat = String::new();
372            while i < bytes.len() && bytes[i] != b'/' {
373                // Regex source escapes: `\[`, `\d`, etc. In the JSON
374                // output these must be double-escaped so the result
375                // is a valid JSON string that, when parsed, yields
376                // the regex source verbatim.
377                if bytes[i] == b'\\' && i + 1 < bytes.len() {
378                    // Preserve the backslash for regex semantics but
379                    // escape it for JSON.
380                    pat.push('\\');
381                    pat.push('\\');
382                    pat.push(bytes[i + 1] as char);
383                    i += 2;
384                    continue;
385                }
386                if bytes[i] == b'"' {
387                    pat.push_str("\\\"");
388                    i += 1;
389                    continue;
390                }
391                pat.push(bytes[i] as char);
392                i += 1;
393            }
394            i += 1; // closing /
395            // Skip flags (g / i / m / s etc.)
396            while i < bytes.len() && bytes[i].is_ascii_alphabetic() {
397                i += 1;
398            }
399            out.push('"');
400            out.push_str(&pat);
401            out.push('"');
402            expect_key = false;
403            continue;
404        }
405        // Unquoted key. Alphanumeric / underscore / dash / dot until `:`.
406        if expect_key && (c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'.') {
407            let mut key = String::new();
408            while i < bytes.len()
409                && (bytes[i].is_ascii_alphanumeric()
410                    || bytes[i] == b'_'
411                    || bytes[i] == b'-'
412                    || bytes[i] == b'.')
413            {
414                key.push(bytes[i] as char);
415                i += 1;
416            }
417            out.push('"');
418            out.push_str(&key);
419            out.push('"');
420            expect_key = false;
421            continue;
422        }
423        // Double-quoted string — pass through.
424        if c == b'"' {
425            out.push('"');
426            i += 1;
427            while i < bytes.len() && bytes[i] != b'"' {
428                if bytes[i] == b'\\' && i + 1 < bytes.len() {
429                    out.push(bytes[i] as char);
430                    out.push(bytes[i + 1] as char);
431                    i += 2;
432                    continue;
433                }
434                out.push(bytes[i] as char);
435                i += 1;
436            }
437            out.push('"');
438            i += 1;
439            expect_key = false;
440            continue;
441        }
442        if c == b':' {
443            out.push(':');
444            i += 1;
445            expect_key = false;
446            continue;
447        }
448        // Colon or other punct — pass through.
449        out.push(c as char);
450        i += 1;
451    }
452    out
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use tempfile::TempDir;
459
460    fn write(tmp: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
461        let p = tmp.path().join(name);
462        std::fs::write(&p, content).unwrap();
463        p
464    }
465
466    #[test]
467    fn load_single_fixture() {
468        let tmp = TempDir::new().unwrap();
469        let path = write(
470            &tmp,
471            "reg.json",
472            r#"{
473                "version": 1,
474                "fixtures": {
475                    "prime-search-history": {
476                        "testID": "qa-chip-prime-search-history",
477                        "signal": {
478                            "regex": "\\[fixture\\] prime-search-history: seeded (\\d+) rows",
479                            "level": "log"
480                        },
481                        "timeoutMs": 8000
482                    }
483                }
484            }"#,
485        );
486        let reg = FixtureRegistry::load(&path).unwrap();
487        let f = reg.require("prime-search-history").unwrap();
488        assert_eq!(f.test_id, "qa-chip-prime-search-history");
489        assert_eq!(f.timeout_ms, 8000);
490        assert_eq!(f.signal.level.as_deref(), Some("log"));
491    }
492
493    #[test]
494    fn not_found_lists_available() {
495        let tmp = TempDir::new().unwrap();
496        let path = write(
497            &tmp,
498            "reg.json",
499            r#"{"fixtures":{
500                "a":{"testID":"t-a","signal":{"regex":"a"},"timeoutMs":100},
501                "b":{"testID":"t-b","signal":{"regex":"b"},"timeoutMs":100}
502            }}"#,
503        );
504        let reg = FixtureRegistry::load(&path).unwrap();
505        let err = reg.require("nope").unwrap_err();
506        match err {
507            RegistryError::NotFound { known, .. } => {
508                assert_eq!(known, vec!["a".to_string(), "b".to_string()]);
509            }
510            other => panic!("expected NotFound, got {other:?}"),
511        }
512    }
513
514    #[test]
515    fn malformed_json_surfaces_error() {
516        let tmp = TempDir::new().unwrap();
517        let path = write(&tmp, "reg.json", "not json");
518        let err = FixtureRegistry::load(&path).unwrap_err();
519        matches!(err, RegistryError::Malformed { .. });
520    }
521
522    #[test]
523    fn multi_fixture_ids_iterate_deterministic() {
524        let tmp = TempDir::new().unwrap();
525        let path = write(
526            &tmp,
527            "reg.json",
528            r#"{"fixtures":{
529                "z":{"testID":"z","signal":{"regex":"."},"timeoutMs":100},
530                "a":{"testID":"a","signal":{"regex":"."},"timeoutMs":100},
531                "m":{"testID":"m","signal":{"regex":"."},"timeoutMs":100}
532            }}"#,
533        );
534        let reg = FixtureRegistry::load(&path).unwrap();
535        let ids: Vec<&String> = reg.ids().collect();
536        assert_eq!(ids, vec!["a", "m", "z"]);
537    }
538}
539
540#[cfg(test)]
541mod ts_reader_tests {
542    use super::*;
543    use std::io::Write;
544    use tempfile::TempDir;
545
546    fn write_ts(tmp: &TempDir, name: &str, content: &str) -> std::path::PathBuf {
547        let p = tmp.path().join(name);
548        std::fs::File::create(&p)
549            .unwrap()
550            .write_all(content.as_bytes())
551            .unwrap();
552        p
553    }
554
555    #[test]
556    fn ts_registry_basic() {
557        let tmp = TempDir::new().unwrap();
558        let path = write_ts(
559            &tmp,
560            "registry.ts",
561            r#"
562export const FIXTURES: Record<string, any> = {
563  'prime-search-history': {
564    testID: 'qa-chip-prime-search-history',
565    signal: { regex: /\[fixture\] prime-search-history: seeded (\d+) rows/, level: 'log' },
566    timeoutMs: 8000,
567  },
568};
569"#,
570        );
571        let reg = FixtureRegistry::load(&path).unwrap();
572        let f = reg.get("prime-search-history").unwrap();
573        assert_eq!(f.test_id, "qa-chip-prime-search-history");
574        assert_eq!(f.timeout_ms, 8000);
575        assert!(f.signal.regex.contains("fixture"));
576    }
577
578    #[test]
579    fn ts_registry_multi_fixtures() {
580        let tmp = TempDir::new().unwrap();
581        let path = write_ts(
582            &tmp,
583            "registry.ts",
584            r#"
585export const FIXTURES = {
586  'foo': { testID: 't-foo', signal: { regex: 'foo-ready' }, timeoutMs: 5000 },
587  'bar': { testID: 't-bar', signal: { regex: 'bar-ready' }, timeoutMs: 3000 },
588};
589"#,
590        );
591        let reg = FixtureRegistry::load(&path).unwrap();
592        assert!(reg.get("foo").is_some());
593        assert!(reg.get("bar").is_some());
594    }
595}