chore(apply-patch) scenarios for e2e testing (#7567)

## Summary
This PR introduces an End to End test suite for apply-patch, so we can
easily validate behavior against other implementations as well.

## Testing
- [x] These are tests
This commit is contained in:
Dylan Hurd 2025-12-04 16:20:54 -08:00 committed by GitHub
parent 0972cd9404
commit 073a8533b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 277 additions and 0 deletions

View file

@ -0,0 +1 @@
** text eol=lf

View file

@ -0,0 +1 @@
This is a new file

View file

@ -0,0 +1,4 @@
*** Begin Patch
*** Add File: bar.md
+This is a new file
*** End Patch

View file

@ -0,0 +1,2 @@
line1
changed

View file

@ -0,0 +1 @@
obsolete

View file

@ -0,0 +1,2 @@
line1
line2

View file

@ -0,0 +1,9 @@
*** Begin Patch
*** Add File: nested/new.txt
+created
*** Delete File: delete.txt
*** Update File: modify.txt
@@
-line2
+changed
*** End Patch

View file

@ -0,0 +1,4 @@
line1
changed2
line3
changed4

View file

@ -0,0 +1,4 @@
line1
line2
line3
line4

View file

@ -0,0 +1,9 @@
*** Begin Patch
*** Update File: multi.txt
@@
-line2
+changed2
@@
-line4
+changed4
*** End Patch

View file

@ -0,0 +1 @@
unrelated file

View file

@ -0,0 +1 @@
old content

View file

@ -0,0 +1 @@
unrelated file

View file

@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@
-old content
+new content
*** End Patch

View file

@ -0,0 +1,2 @@
*** Begin Patch
*** End Patch

View file

@ -0,0 +1,2 @@
line1
line2

View file

@ -0,0 +1,2 @@
line1
line2

View file

@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: modify.txt
@@
-missing
+changed
*** End Patch

View file

@ -0,0 +1,3 @@
*** Begin Patch
*** Delete File: missing.txt
*** End Patch

View file

@ -0,0 +1,3 @@
*** Begin Patch
*** Update File: foo.txt
*** End Patch

View file

@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: missing.txt
@@
-old
+new
*** End Patch

View file

@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: old/name.txt
*** Move to: renamed/dir/name.txt
@@
-from
+new
*** End Patch

View file

@ -0,0 +1,4 @@
*** Begin Patch
*** Add File: duplicate.txt
+new content
*** End Patch

View file

@ -0,0 +1,3 @@
*** Begin Patch
*** Delete File: dir
*** End Patch

View file

@ -0,0 +1,3 @@
*** Begin Patch
*** Frobnicate File: foo
*** End Patch

View file

@ -0,0 +1,2 @@
first line
second line

View file

@ -0,0 +1,7 @@
*** Begin Patch
*** Update File: no_newline.txt
@@
-no newline at end
+first line
+second line
*** End Patch

View file

@ -0,0 +1,8 @@
*** Begin Patch
*** Add File: created.txt
+hello
*** Update File: missing.txt
@@
-old
+new
*** End Patch

View file

@ -0,0 +1,4 @@
line1
line2
added line 1
added line 2

View file

@ -0,0 +1,2 @@
line1
line2

View file

@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: input.txt
@@
+added line 1
+added line 2
*** End Patch

View file

@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: foo.txt
@@
-old
+new
*** End Patch

View file

@ -0,0 +1,6 @@
*** Begin Patch
*** Update File: file.txt
@@
-one
+two
*** End Patch

View file

@ -0,0 +1,18 @@
# Overview
This directory is a collection of end to end tests for the apply-patch specification, meant to be easily portable to other languages or platforms.
# Specification
Each test case is one directory, composed of input state (input/), the patch operation (patch.txt), and the expected final state (expected/). This structure is designed to keep tests simple (i.e. test exactly one patch at a time) while still providing enough flexibility to test any given operation across files.
Here's what this would look like for a simple test apply-patch test case to create a new file:
```
001_add/
input/
foo.md
expected/
foo.md
bar.md
patch.txt
```

View file

@ -1,3 +1,4 @@
mod cli;
mod scenarios;
#[cfg(not(target_os = "windows"))]
mod tool;

View file

@ -0,0 +1,114 @@
use assert_cmd::prelude::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use tempfile::tempdir;
#[test]
fn test_apply_patch_scenarios() -> anyhow::Result<()> {
for scenario in fs::read_dir("tests/fixtures/scenarios")? {
let scenario = scenario?;
let path = scenario.path();
if path.is_dir() {
run_apply_patch_scenario(&path)?;
}
}
Ok(())
}
/// Reads a scenario directory, copies the input files to a temporary directory, runs apply-patch,
/// and asserts that the final state matches the expected state exactly.
fn run_apply_patch_scenario(dir: &Path) -> anyhow::Result<()> {
let tmp = tempdir()?;
// Copy the input files to the temporary directory
let input_dir = dir.join("input");
if input_dir.is_dir() {
copy_dir_recursive(&input_dir, tmp.path())?;
}
// Read the patch.txt file
let patch = fs::read_to_string(dir.join("patch.txt"))?;
// Run apply_patch in the temporary directory. We intentionally do not assert
// on the exit status here; the scenarios are specified purely in terms of
// final filesystem state, which we compare below.
Command::cargo_bin("apply_patch")?
.arg(patch)
.current_dir(tmp.path())
.output()?;
// Assert that the final state matches the expected state exactly
let expected_dir = dir.join("expected");
let expected_snapshot = snapshot_dir(&expected_dir)?;
let actual_snapshot = snapshot_dir(tmp.path())?;
assert_eq!(
actual_snapshot,
expected_snapshot,
"Scenario {} did not match expected final state",
dir.display()
);
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
File(Vec<u8>),
Dir,
}
fn snapshot_dir(root: &Path) -> anyhow::Result<BTreeMap<PathBuf, Entry>> {
let mut entries = BTreeMap::new();
if root.is_dir() {
snapshot_dir_recursive(root, root, &mut entries)?;
}
Ok(entries)
}
fn snapshot_dir_recursive(
base: &Path,
dir: &Path,
entries: &mut BTreeMap<PathBuf, Entry>,
) -> anyhow::Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let Some(stripped) = path.strip_prefix(base).ok() else {
continue;
};
let rel = stripped.to_path_buf();
let file_type = entry.file_type()?;
if file_type.is_dir() {
entries.insert(rel.clone(), Entry::Dir);
snapshot_dir_recursive(base, &path, entries)?;
} else if file_type.is_file() {
let contents = fs::read(&path)?;
entries.insert(rel, Entry::File(contents));
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> anyhow::Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if file_type.is_dir() {
fs::create_dir_all(&dest_path)?;
copy_dir_recursive(&path, &dest_path)?;
} else if file_type.is_file() {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&path, &dest_path)?;
}
}
Ok(())
}