turbopack_test_utils/
snapshot.rs1use 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
19pub 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 .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 let content: RcStr = formatted
68 .as_str()
69 .replace(workspace_root, "WORKSPACE_ROOT")
70 .replace(&*ANSI_REGEX, "")
71 .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
183async 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}