aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-04 05:25:30 +0900
committernsfisis <nsfisis@gmail.com>2026-08-04 05:43:36 +0900
commit2cb2f128cffb4e664145c9a8a392b9666d014459 (patch)
tree1b14c97f384dac7e0b708399553914be914fcf44 /crates
parentb1ec6db345f1b2fe67ccf39fd0d2b77d85cb18c5 (diff)
downloadphp-shirabe-2cb2f128cffb4e664145c9a8a392b9666d014459.tar.gz
php-shirabe-2cb2f128cffb4e664145c9a8a392b9666d014459.tar.zst
php-shirabe-2cb2f128cffb4e664145c9a8a392b9666d014459.zip
test(plugin): compare a real-plugin install against upstream Composer
Run upstream Composer and Shirabe over pristine copies of a fixture project at the same path and require composer.lock, the whole vendor tree (including the plugin-generated GeneratedConfig.php), the plugin's IO lines and the exit code to match byte for byte. The plugin under test (phpstan/extension-installer 1.4.3) is downloaded by fixtures/e2e/fetch — pinned to an upstream commit and hash-verified — into a git-ignored directory rather than committed; the test skips while it is absent, like the other real-PHP prerequisites. Its dependencies are minimal stand-ins resolved from local repositories, so test runs stay offline. A zip dist would exercise the known lossy-string byte-precision debt in RemoteFilesystem, so the fixture serves the plugin through a path dist until that is resolved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe/tests/plugin/e2e_extension_installer_test.rs163
-rwxr-xr-xcrates/shirabe/tests/plugin/fixtures/e2e/fetch33
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/composer.json11
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/extension.neon2
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-phpstan-tools/composer.json5
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e/packages/phpstan-phpstan/composer.json5
-rw-r--r--crates/shirabe/tests/plugin/fixtures/e2e/project/composer.json71
-rw-r--r--crates/shirabe/tests/plugin/main.rs1
8 files changed, 291 insertions, 0 deletions
diff --git a/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs b/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs
new file mode 100644
index 00000000..c78af3cd
--- /dev/null
+++ b/crates/shirabe/tests/plugin/e2e_extension_installer_test.rs
@@ -0,0 +1,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"
+ );
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e/fetch b/crates/shirabe/tests/plugin/fixtures/e2e/fetch
new file mode 100755
index 00000000..a4179955
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e/fetch
@@ -0,0 +1,33 @@
+#!/bin/sh
+# Fetches the external plugin the E2E test runs against, into the git-ignored ext/
+# directory. The plugin is pinned to an immutable upstream commit and the extracted
+# files are verified by hash, so the test stays deterministic without the third-party
+# source ever entering this repository. Requires network once; the E2E test skips
+# itself while ext/ is absent.
+set -eu
+
+# phpstan/extension-installer 1.4.3
+commit=85e90b3942d06b2326fba0403ec24fe912372936
+dir="$(dirname "$0")/ext/phpstan-extension-installer-1.4.3"
+
+if [ -f "$dir/src/Plugin.php" ]; then
+ echo "already fetched: $dir"
+ exit 0
+fi
+
+mkdir -p "$dir"
+curl -fsSL "https://codeload.github.com/phpstan/extension-installer/tar.gz/$commit" \
+ | tar -xz -C "$dir" --strip-components=1
+
+# GitHub archives are content-addressed by the commit, but the archive encoding is not
+# guaranteed stable; the extracted files are what the test consumes, so they are what
+# gets pinned.
+(cd "$dir" && sha256sum -c --quiet) <<'EOF' || { rm -rf "$dir"; echo "hash mismatch; discarded the fetched tree" >&2; exit 1; }
+92d8a5f0f9da0ebe198fe466bc4efc1793d90d8da93efd912fdc9dc414a56885 LICENSE
+76197285d0c2a8dec7cff9f8562638bda879367829fd982281cba53068c7f153 README.md
+71f6125d095522d8b24925d9144d361b2419f41be6174607b026473ae65c2604 composer.json
+fc46b5548e9656fab230ed4b97fdf13b54712743d04498374d40ebae2610bac1 src/GeneratedConfig.php
+5803a3fb8c272d52848e4aa240707370e301fa081fc632b487fafacfe4985af7 src/Plugin.php
+EOF
+
+echo "fetched: $dir"
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/composer.json
new file mode 100644
index 00000000..066c25e3
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/composer.json
@@ -0,0 +1,11 @@
+{
+ "name": "acme/extension",
+ "version": "1.0.0",
+ "type": "phpstan-extension",
+ "description": "PHPStan extension fixture handled by phpstan/extension-installer.",
+ "extra": {
+ "phpstan": {
+ "includes": ["extension.neon"]
+ }
+ }
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/extension.neon b/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/extension.neon
new file mode 100644
index 00000000..e9bca97b
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-extension/extension.neon
@@ -0,0 +1,2 @@
+parameters:
+ acmeExtensionLoaded: true
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-phpstan-tools/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-phpstan-tools/composer.json
new file mode 100644
index 00000000..074dbe8c
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e/packages/acme-phpstan-tools/composer.json
@@ -0,0 +1,5 @@
+{
+ "name": "acme/phpstan-tools",
+ "version": "1.0.0",
+ "description": "Package whose name mentions phpstan without being an extension; exercises the NOT_INSTALLED path."
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e/packages/phpstan-phpstan/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e/packages/phpstan-phpstan/composer.json
new file mode 100644
index 00000000..cbd11818
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e/packages/phpstan-phpstan/composer.json
@@ -0,0 +1,5 @@
+{
+ "name": "phpstan/phpstan",
+ "version": "2.1.0",
+ "description": "Minimal stand-in for phpstan/phpstan used by the extension-installer E2E fixture."
+}
diff --git a/crates/shirabe/tests/plugin/fixtures/e2e/project/composer.json b/crates/shirabe/tests/plugin/fixtures/e2e/project/composer.json
new file mode 100644
index 00000000..d16b5383
--- /dev/null
+++ b/crates/shirabe/tests/plugin/fixtures/e2e/project/composer.json
@@ -0,0 +1,71 @@
+{
+ "name": "shirabe/e2e-extension-installer",
+ "description": "E2E fixture project: install phpstan/extension-installer and let it generate its config.",
+ "repositories": [
+ {
+ "type": "package",
+ "package": {
+ "name": "phpstan/extension-installer",
+ "version": "1.4.3",
+ "type": "composer-plugin",
+ "license": [
+ "MIT"
+ ],
+ "autoload": {
+ "psr-4": {
+ "PHPStan\\ExtensionInstaller\\": "src/"
+ }
+ },
+ "require": {
+ "php": "^7.2 || ^8.0",
+ "composer-plugin-api": "^2.0",
+ "phpstan/phpstan": "^1.9.0 || ^2.0"
+ },
+ "extra": {
+ "class": "PHPStan\\ExtensionInstaller\\Plugin"
+ },
+ "dist": {
+ "type": "path",
+ "url": "../ext/phpstan-extension-installer-1.4.3"
+ },
+ "transport-options": {
+ "symlink": false
+ }
+ }
+ },
+ {
+ "type": "path",
+ "url": "../packages/phpstan-phpstan",
+ "options": {
+ "symlink": false
+ }
+ },
+ {
+ "type": "path",
+ "url": "../packages/acme-extension",
+ "options": {
+ "symlink": false
+ }
+ },
+ {
+ "type": "path",
+ "url": "../packages/acme-phpstan-tools",
+ "options": {
+ "symlink": false
+ }
+ },
+ {
+ "packagist.org": false
+ }
+ ],
+ "require": {
+ "phpstan/extension-installer": "1.4.3",
+ "acme/extension": "1.0.0",
+ "acme/phpstan-tools": "1.0.0"
+ },
+ "config": {
+ "allow-plugins": {
+ "phpstan/extension-installer": true
+ }
+ }
+}
diff --git a/crates/shirabe/tests/plugin/main.rs b/crates/shirabe/tests/plugin/main.rs
index 3a66bb75..b2631eb7 100644
--- a/crates/shirabe/tests/plugin/main.rs
+++ b/crates/shirabe/tests/plugin/main.rs
@@ -3,5 +3,6 @@ mod async_runtime;
#[path = "../common/config_stub.rs"]
mod config_stub;
+mod e2e_extension_installer_test;
mod plugin_installer_test;
mod subscriber_test;