turbopack_test_utils/
snapshot.rs

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