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