Skip to main content

turbopack_test_utils/
snapshot.rs

1use std::{env, path::PathBuf, sync::LazyLock};
2
3use anyhow::{Result, bail};
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6use similar::TextDiff;
7use turbo_rcstr::RcStr;
8use turbo_tasks::{ReadRef, TryJoinIterExt, Vc};
9use turbo_tasks_fs::{
10    DirectoryContent, DirectoryEntry, File, FileContent, FileSystemEntryType, FileSystemPath,
11};
12use turbo_tasks_hash::{encode_hex, hash_xxh3_hash64};
13use turbopack_cli_utils::issue::{LogOptions, format_issue};
14use turbopack_core::{
15    asset::AssetContent,
16    issue::{IssueSeverity, PlainIssue, StyledString},
17};
18
19// Updates the existing snapshot outputs with the actual outputs of this run.
20// e.g. `UPDATE=1 cargo test -p turbopack-tests -- test_my_pattern`
21pub static UPDATE: LazyLock<bool> = LazyLock::new(|| env::var("UPDATE").unwrap_or_default() == "1");
22
23static ANSI_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[\d+m").unwrap());
24
25pub async fn snapshot_issues<I: IntoIterator<Item = ReadRef<PlainIssue>>>(
26    captured_issues: I,
27    issues_path: FileSystemPath,
28    workspace_root: &str,
29) -> Result<()> {
30    let expected_issues = expected(issues_path.clone()).await?;
31    let mut seen = FxHashSet::default();
32    for plain_issue in captured_issues.into_iter() {
33        let title = styled_string_to_file_safe_string(&plain_issue.title)
34            .replace('/', "__")
35            // We replace "*", "?", and '"' because they're not allowed in filenames on Windows.
36            .replace('*', "__star__")
37            .replace('"', "__quo__")
38            .replace('?', "__q__")
39            .replace(':', "__c__");
40        let title = if title.len() > 50 {
41            &title[0..50]
42        } else {
43            &title
44        };
45        let hash = encode_hex(plain_issue.internal_hash_ref(true));
46
47        let path = issues_path.join(&format!("{title}-{}.txt", &hash[0..6]))?;
48        if !seen.insert(path.clone()) {
49            continue;
50        }
51
52        let formatted = format_issue(
53            &plain_issue,
54            None,
55            &LogOptions {
56                current_dir: PathBuf::new(),
57                project_dir: PathBuf::new(),
58                show_all: true,
59                log_detail: true,
60                log_level: IssueSeverity::Info,
61            },
62        );
63
64        // Annoyingly, the PlainIssue.source -> PlainIssueSource.asset ->
65        // PlainSource.path -> Vc<FileSystemPath>.fs -> DiskFileSystem.root changes
66        // for everyone.
67        let content: RcStr = formatted
68            .as_str()
69            .replace(workspace_root, "WORKSPACE_ROOT")
70            .replace(&*ANSI_REGEX, "")
71            // Normalize syspaths from Windows. These appear in stack traces.
72            .replace("\\\\", "/")
73            .into();
74
75        let asset = AssetContent::file(FileContent::Content(File::from(content)).cell());
76
77        diff(path, asset).await?;
78    }
79
80    matches_expected(expected_issues, seen).await
81}
82
83pub async fn expected(dir: FileSystemPath) -> Result<FxHashSet<FileSystemPath>> {
84    let mut expected = FxHashSet::default();
85    let entries = dir.read_dir().await?;
86    if let DirectoryContent::Entries(entries) = &*entries {
87        for (file, entry) in entries {
88            match entry {
89                DirectoryEntry::File(file) => {
90                    expected.insert(file.clone());
91                }
92                _ => bail!(
93                    "expected file at {}, found {:?}",
94                    file,
95                    FileSystemEntryType::from(entry)
96                ),
97            }
98        }
99    }
100    Ok(expected)
101}
102
103pub async fn matches_expected(
104    expected: FxHashSet<FileSystemPath>,
105    seen: FxHashSet<FileSystemPath>,
106) -> Result<()> {
107    for path in diff_paths(&expected, &seen).await? {
108        let p = &path.path;
109        if *UPDATE {
110            remove_file(path.clone()).await?;
111            println!("removed file {p}");
112        } else {
113            bail!("expected file {}, but it was not emitted", p);
114        }
115    }
116    Ok(())
117}
118
119pub async fn diff(path: FileSystemPath, actual: Vc<AssetContent>) -> Result<()> {
120    let path_str = &path.path;
121    let expected = AssetContent::file(path.read());
122
123    let actual = get_contents(actual).await?;
124    let expected = get_contents(expected).await?;
125
126    if actual != expected {
127        if let Some(actual) = actual {
128            if *UPDATE {
129                let content = FileContent::Content(File::from(RcStr::from(actual))).cell();
130                path.write(content).await?;
131                println!("updated contents of {path_str}");
132            } else {
133                if expected.is_none() {
134                    eprintln!("new file {path_str} detected:");
135                } else {
136                    eprintln!("contents of {path_str} did not match:");
137                }
138                let expected = expected.unwrap_or_default();
139                let diff = TextDiff::from_lines(&expected, &actual);
140                eprintln!(
141                    "{}",
142                    diff.unified_diff()
143                        .context_radius(3)
144                        .header("expected", "actual")
145                );
146                bail!("contents of {path_str} did not match");
147            }
148        } else {
149            bail!("{path_str} was not generated");
150        }
151    }
152
153    Ok(())
154}
155
156async fn get_contents(file: Vc<AssetContent>) -> Result<Option<String>> {
157    Ok(match &*file.await? {
158        AssetContent::File(file) => match &*file.await? {
159            FileContent::NotFound => None,
160            FileContent::Content(expected) => {
161                let rope = expected.content();
162                let str = rope.to_str();
163                match str {
164                    Ok(str) => Some(str.trim().to_string()),
165                    Err(_) => {
166                        let hash = hash_xxh3_hash64(rope);
167                        Some(format!("Binary content {hash:016x}"))
168                    }
169                }
170            }
171        },
172        AssetContent::Redirect { target, link_type } => Some(format!(
173            "Redirect {{ target: {target}, link_type: {link_type:?} }}"
174        )),
175    })
176}
177
178async fn remove_file(path: FileSystemPath) -> Result<()> {
179    path.write(FileContent::NotFound.cell()).await?;
180    Ok(())
181}
182
183/// Values in left that are not in right.
184/// FileSystemPath hashes as a Vc, not as the file path, so we need to get
185/// the path to properly diff.
186async fn diff_paths(
187    left: &FxHashSet<FileSystemPath>,
188    right: &FxHashSet<FileSystemPath>,
189) -> Result<FxHashSet<FileSystemPath>> {
190    let mut map = left
191        .iter()
192        .map(|p| async move { Ok((p.path.clone(), p.clone())) })
193        .try_join()
194        .await?
195        .iter()
196        .cloned()
197        .collect::<FxHashMap<_, _>>();
198    for p in right {
199        map.remove(&p.path);
200    }
201    Ok(map.values().cloned().collect())
202}
203
204fn styled_string_to_file_safe_string(styled_string: &StyledString) -> String {
205    match styled_string {
206        StyledString::Line(parts) => {
207            let mut string = String::new();
208            string += "__l_";
209            for part in parts {
210                string.push_str(&styled_string_to_file_safe_string(part));
211            }
212            string += "__";
213            string
214        }
215        StyledString::Stack(parts) => {
216            let mut string = String::new();
217            string += "__s_";
218            for part in parts {
219                string.push_str(&styled_string_to_file_safe_string(part));
220                string.push('_');
221            }
222            string += "__";
223            string
224        }
225        StyledString::Text(string) => string.to_string(),
226        StyledString::Code(string) => format!("__c_{string}__"),
227        StyledString::Strong(string) => format!("__{string}__"),
228    }
229}