aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs
blob: c78af3cd1c5664d1d5a25c6fab6ef92db6b87e78 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
//! Real-plugin E2E compatibility check: upstream Composer and Shirabe each
//! run `install` on a pristine copy of the pinned phpstan/extension-installer fixture project
//! at the same filesystem path, and every produced artifact (composer.lock and the whole
//! vendor tree, including the plugin-generated GeneratedConfig.php) must match byte for byte.
//!
//! Prerequisites: the PHP runtime, the Composer checkout, and the plugin under test in
//! `fixtures/e2e/ext/` — run `fixtures/e2e/fetch` once to populate it. The test skips while
//! any of these is missing. Test runs themselves are offline: the fixture project resolves
//! everything from local repositories.

use crate::plugin_installer_test::{lock_php_worker, php_runtime_available};
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
use tempfile::TempDir;

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/plugin/fixtures/e2e")
}

/// The upstream Composer checkout used as the comparison oracle (and as the PHP runtime of
/// the worker). Absent checkout means the oracle cannot run; the test returns early,
/// following the convention of the non-mock tests in `shirabe-php-rpc`.
fn upstream_composer_bin() -> Option<PathBuf> {
    let root = match std::env::var("SHIRABE_COMPOSER_PHP_DIR") {
        Ok(dir) => PathBuf::from(dir),
        Err(_) => Path::new(env!("CARGO_MANIFEST_DIR")).join("../../composer"),
    };
    let bin = root.join("bin/composer");
    if bin.is_file() && root.join("vendor/autoload.php").is_file() {
        Some(bin)
    } else {
        None
    }
}

fn copy_dir(from: &Path, to: &Path) {
    std::fs::create_dir_all(to).unwrap();
    for entry in std::fs::read_dir(from).unwrap() {
        let entry = entry.unwrap();
        let target = to.join(entry.file_name());
        if entry.file_type().unwrap().is_dir() {
            copy_dir(&entry.path(), &target);
        } else {
            std::fs::copy(entry.path(), &target).unwrap();
        }
    }
}

/// Every file under `dir` as relative path => contents.
fn snapshot_tree(dir: &Path) -> IndexMap<String, Vec<u8>> {
    let mut files = IndexMap::new();
    fn walk(root: &Path, dir: &Path, files: &mut IndexMap<String, Vec<u8>>) {
        for entry in std::fs::read_dir(dir).unwrap() {
            let entry = entry.unwrap();
            if entry.file_type().unwrap().is_dir() {
                walk(root, &entry.path(), files);
            } else {
                let relative = entry
                    .path()
                    .strip_prefix(root)
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string();
                files.insert(relative, std::fs::read(entry.path()).unwrap());
            }
        }
    }
    walk(dir, dir, &mut files);
    files
}

struct InstallRun {
    exit_code: i32,
    /// The plugin's own IO lines, order preserved (progress rendering differs between the two
    /// implementations and is not compared).
    plugin_output: Vec<String>,
    artifacts: IndexMap<String, Vec<u8>>,
}

fn run_install(work: &Path, program: &str, args: &[&str]) -> InstallRun {
    let project = work.join("project");
    copy_dir(&fixture_dir(), work);
    let output = std::process::Command::new(program)
        .args(args)
        .arg("install")
        .current_dir(&project)
        .env("COMPOSER_HOME", work.join("home"))
        .env("COMPOSER_CACHE_DIR", work.join("cache"))
        .env("COMPOSER_NO_INTERACTION", "1")
        .output()
        .unwrap();
    let mut artifacts = snapshot_tree(&project.join("vendor"));
    artifacts.insert(
        "composer.lock".to_string(),
        std::fs::read(project.join("composer.lock")).unwrap_or_default(),
    );
    // Directory walk order is filesystem-dependent; a canonical order keys the comparison.
    artifacts.sort_keys();
    let text = format!(
        "{}{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );
    let plugin_output = text
        .lines()
        .filter(|line| line.starts_with("phpstan/extension-installer:") || line.starts_with("> "))
        .map(str::to_string)
        .collect();
    // The next run reuses the same path so absolute paths embedded in the artifacts
    // (GeneratedConfig.php's install_path) compare byte for byte.
    std::fs::remove_dir_all(&project).unwrap();
    InstallRun {
        exit_code: output.status.code().unwrap_or(-1),
        plugin_output,
        artifacts,
    }
}

#[test]
fn test_extension_installer_install_matches_upstream_composer() {
    if !php_runtime_available() {
        return;
    }
    let Some(composer_bin) = upstream_composer_bin() else {
        return;
    };
    // Populated by `fixtures/e2e/fetch` (network, one-off).
    if !fixture_dir()
        .join("ext/phpstan-extension-installer-1.4.3/src/Plugin.php")
        .is_file()
    {
        return;
    }
    let _worker = lock_php_worker();

    let work = TempDir::new().unwrap();
    let composer_bin = composer_bin.to_str().unwrap().to_string();
    let upstream = run_install(work.path(), "php", &[&composer_bin]);
    let shirabe = run_install(work.path(), env!("CARGO_BIN_EXE_shirabe"), &[]);

    assert_eq!(0, upstream.exit_code, "upstream Composer must succeed");
    assert_eq!(upstream.exit_code, shirabe.exit_code);
    assert_eq!(upstream.plugin_output, shirabe.plugin_output);
    assert_eq!(
        vec![
            "phpstan/extension-installer: Extensions installed",
            "> acme/extension: installed",
            "> acme/phpstan-tools: not supported",
        ],
        upstream.plugin_output
    );

    let upstream_files: Vec<&String> = upstream.artifacts.keys().collect();
    let shirabe_files: Vec<&String> = shirabe.artifacts.keys().collect();
    assert_eq!(upstream_files, shirabe_files);
    for (path, contents) in &upstream.artifacts {
        assert_eq!(
            contents, &shirabe.artifacts[path],
            "artifact `{path}` differs between upstream Composer and Shirabe"
        );
    }
}