diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51dbfb7d..3228c1f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,11 @@ jobs: - name: Build forkpress run: cargo build --release --target x86_64-unknown-linux-musl -p forkpress-cli --bin forkpress + - name: Production CLI e2e + run: cargo test -p forkpress-e2e-tests --test production_cli -- --ignored --nocapture + env: + FORKPRESS_E2E_BIN: target/x86_64-unknown-linux-musl/release/forkpress + - name: COW strategy e2e run: tests/cow/e2e.sh target/x86_64-unknown-linux-musl/release/forkpress @@ -112,6 +117,11 @@ jobs: - name: Build forkpress run: cargo build --release --target ${{ matrix.target }} -p forkpress-cli --bin forkpress + - name: Production CLI e2e + run: cargo test -p forkpress-e2e-tests --test production_cli -- --ignored --nocapture + env: + FORKPRESS_E2E_BIN: target/${{ matrix.target }}/release/forkpress + - name: COW strategy e2e through APFS sparsebundle run: tests/cow/e2e.sh target/${{ matrix.target }}/release/forkpress env: diff --git a/Cargo.lock b/Cargo.lock index 6b3ab3c5..028e0b65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,6 +73,21 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -97,6 +112,17 @@ dependencies = [ "objc2", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -222,6 +248,12 @@ dependencies = [ "syn", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.11.2" @@ -272,6 +304,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "filetime" version = "0.2.27" @@ -293,6 +331,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "forkpress-cas-ffi" version = "0.1.0" @@ -340,6 +384,15 @@ dependencies = [ "clap", ] +[[package]] +name = "forkpress-e2e-tests" +version = "0.1.13-cow.1" +dependencies = [ + "assert_cmd", + "libc", + "tempfile", +] + [[package]] name = "forkpress-git" version = "0.1.13-cow.1" @@ -377,6 +430,28 @@ dependencies = [ "libc", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.0" @@ -404,6 +479,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -411,7 +492,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] @@ -420,6 +503,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -503,6 +598,12 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -515,6 +616,43 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -533,6 +671,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redb" version = "4.1.0" @@ -551,6 +695,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" + [[package]] name = "rustix" version = "1.1.4" @@ -573,6 +723,54 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "sha2" version = "0.11.0" @@ -618,6 +816,25 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -650,12 +867,27 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -666,6 +898,58 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -690,6 +974,100 @@ dependencies = [ "windows-link", ] +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "xattr" version = "1.6.1" @@ -717,6 +1095,12 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zopfli" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 5e7f6f43..62adb563 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/forkpress-git", "experiments/cas/crates/cas-store", "experiments/cas/crates/cas-ffi", + "tests/cli", ] default-members = ["crates/forkpress-cli"] resolver = "2" diff --git a/Makefile b/Makefile index 287cf220..3b35eac7 100644 --- a/Makefile +++ b/Makefile @@ -68,7 +68,7 @@ else ifeq ($(UNAME_S)-$(UNAME_M),Linux-aarch64) FORKPRESS_TARGET ?= aarch64-unknown-linux-musl endif -.PHONY: all clean test test-compat test-branchfs test-cow init-db test-all forkpress forkpress-dev dist dist-dev +.PHONY: all clean test test-compat test-branchfs test-prod-cli test-prod-e2e test-cow init-db test-all forkpress forkpress-dev dist dist-dev all: $(BRANCHFS_EXT_SO) @@ -102,6 +102,13 @@ test-cow: php $(COW_TEST_DIR)/router_paths.php php $(COW_TEST_DIR)/router_lock.php +test-prod-cli: + FORKPRESS_E2E_BIN=target/$(FORKPRESS_TARGET)/release/forkpress \ + cargo test -p forkpress-e2e-tests --test production_cli -- --ignored --nocapture + +test-prod-e2e: test-prod-cli + $(COW_TEST_DIR)/e2e.sh target/$(FORKPRESS_TARGET)/release/forkpress + test-all: test-branchfs test-cow clean: diff --git a/README.md b/README.md index 59d6e142..e054fb5e 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ tooling. - `forkpress stop` stops this site's server and detaches mount-backed storage. - `forkpress stop --all` stops every running ForkPress site server for your user. -- `forkpress server list` lists running site servers. +- `forkpress server start|list|stop` manages background site servers. - `forkpress branch list` lists local branches. - `forkpress branch create [--from main]` creates a COW branch. - `forkpress branch reset --from ` replaces one COW branch with @@ -443,11 +443,15 @@ tooling. count, and Git ref path. - `forkpress branch delete ` removes a COW branch. `main` cannot be deleted. +- `forkpress branchctl ...` is an alias for `forkpress branch ...`. - `forkpress clone [remote] [dir]` wraps `git clone`. +- `forkpress git ` passes through to Git, with + `forkpress git branch create [--from main]` handled locally. - `forkpress agents [dir] --count 10 --prefix agent` creates agent branches and worktrees. -- `forkpress commit -m "message"` stages, commits, and pushes the current Git +- `forkpress push -m "message"` stages, commits, and pushes the current Git branch back into ForkPress. +- `forkpress commit -m "message"` is an alias for `forkpress push`. - `forkpress pull` wraps `git pull --rebase --autostash`. - `forkpress logs --file wp|php|server|forkpress|gc|all` prints logs. - `forkpress storage status|mount|detach|compact` diagnoses or manually manages @@ -481,10 +485,23 @@ For fast Rust-only checks without rebuilding PHP: ```bash cargo test --workspace --exclude forkpress-cli cargo test -p forkpress-core --features dev-experiments -FORKPRESS_RUNTIME_BUNDLE=/dev/null cargo test -p forkpress-cli +FORKPRESS_RUNTIME_BUNDLE=/dev/null cargo test -p forkpress-cli --bin forkpress FORKPRESS_RUNTIME_BUNDLE=/dev/null cargo test -p forkpress-cli --features dev-experiments --bin forkpress-dev ``` +Production e2e tests run against a built `forkpress` binary. `test-prod-cli` +runs the Cargo-managed CLI suite across the production command surface, aliases, +validation paths, server lifecycle, branch operations, Git checkout flow, logs, +doctor, storage, and agents. `test-prod-e2e` runs that suite plus the full COW +runtime shell e2e. + +```bash +make dist +make forkpress +make test-prod-cli +make test-prod-e2e +``` + PHP unit tests: ```bash diff --git a/docs/storage-drivers.md b/docs/storage-drivers.md index d226c1e2..9a6afe02 100644 --- a/docs/storage-drivers.md +++ b/docs/storage-drivers.md @@ -199,8 +199,11 @@ Implemented for COW: - `forkpress branch reset --from ` - `forkpress branch show ` - `forkpress branch delete ` +- `forkpress branchctl ...` as an alias for `forkpress branch ...` - `forkpress clone` -- `forkpress commit` +- `forkpress git branch create [--from main]` +- `forkpress push` +- `forkpress commit` as an alias for `forkpress push` - `forkpress pull` - Git-created, Git-updated, and Git-deleted preview branches, one branch per push - `forkpress agents` diff --git a/tests/cli/Cargo.toml b/tests/cli/Cargo.toml new file mode 100644 index 00000000..b48a6868 --- /dev/null +++ b/tests/cli/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "forkpress-e2e-tests" +version = "0.1.13-cow.1" +edition = "2024" +publish = false + +[dev-dependencies] +assert_cmd = "2.1" +libc = "0.2" +tempfile = "3.23" diff --git a/tests/cli/src/lib.rs b/tests/cli/src/lib.rs new file mode 100644 index 00000000..adc58f38 --- /dev/null +++ b/tests/cli/src/lib.rs @@ -0,0 +1 @@ +//! Cargo-managed end-to-end tests for the built ForkPress CLI artifact. diff --git a/tests/cli/tests/production_cli.rs b/tests/cli/tests/production_cli.rs new file mode 100644 index 00000000..27c6b6a8 --- /dev/null +++ b/tests/cli/tests/production_cli.rs @@ -0,0 +1,64 @@ +pub(crate) const PRODUCTION_COMMANDS: &[&str] = &[ + "agents", + "branch", + "branchctl", + "clone", + "commit", + "doctor", + "git", + "init", + "logs", + "pull", + "push", + "serve", + "server", + "start", + "stop", + "storage", +]; + +pub(crate) const DEV_ONLY_COMMANDS: &[&str] = &["backup", "export", "import", "user", "zfs"]; + +macro_rules! argv { + ($($arg:expr),* $(,)?) => {{ + vec![$(std::ffi::OsString::from($arg)),*] + }}; +} + +#[path = "production_cli/branch.rs"] +mod branch; +#[path = "production_cli/git.rs"] +mod git; +#[path = "production_cli/harness.rs"] +mod harness; +#[path = "production_cli/lifecycle.rs"] +mod lifecycle; +#[path = "production_cli/logs_storage.rs"] +mod logs_storage; +#[path = "production_cli/support.rs"] +mod support; +#[path = "production_cli/surface.rs"] +mod surface; + +use harness::Harness; + +#[test] +#[ignore = "requires FORKPRESS_E2E_BIN to point to a built production forkpress binary"] +fn production_cli_exhaustive_e2e() { + let mut harness = Harness::new(); + harness.command_surface(); + harness.parser_and_early_validation_failures(); + harness.no_site_diagnostics(); + harness.init_site(); + harness.server_entrypoints(); + harness.start_main_server_with_serve(); + harness.branch_commands(); + harness.git_checkout_commands(); + harness.agent_worktrees(); + harness.log_commands(); + harness.storage_commands(); + harness.stop_main_server(); + harness.foreground_start_exits_on_sigint(); + harness.post_stop_storage_commands(); + harness.readme_command_docs(); +} diff --git a/tests/cli/tests/production_cli/branch.rs b/tests/cli/tests/production_cli/branch.rs new file mode 100644 index 00000000..1af817b4 --- /dev/null +++ b/tests/cli/tests/production_cli/branch.rs @@ -0,0 +1,343 @@ +use crate::harness::Harness; +use crate::support::{assert_contains, host_for_branch, http_get, step}; + +impl Harness { + pub(crate) fn branch_commands(&self) { + step("branch and branchctl commands"); + + let list = self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "list" + )); + assert_contains(&list, "main", "branch list"); + + let show_main = self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "show", + "main" + )); + assert_contains(&show_main, "forkpress cow branch main", "branch show main"); + + let create = self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "create", + "feature-cli" + )); + assert_contains( + &create, + "forkpress: COW cloned 'main' -> 'feature-cli'", + "branch create", + ); + assert_contains( + &create, + &format!("feature-cli.wp.localhost:{}/", self.port), + "branch create URL hint", + ); + assert!( + self.branch_root("feature-cli") + .join("wp-load.php") + .is_file(), + "feature-cli branch root missing" + ); + + let response = http_get(self.port, &host_for_branch("feature-cli", self.port), "/"); + assert_eq!(response.status, 200, "feature-cli HTTP status"); + assert_contains(&response.body, "Branch: feature-cli", "feature-cli HTTP"); + + let status = self.success(argv!( + "branchctl", + "--work-dir", + self.work_dir.as_os_str(), + "status", + "feature-cli" + )); + assert_contains( + &status, + "forkpress cow branch feature-cli", + "branchctl status", + ); + + self.write_branch_file( + "feature-cli", + "wp-content/feature-cli.txt", + "feature only\n", + ); + assert!( + !self + .branch_root("main") + .join("wp-content/feature-cli.txt") + .exists(), + "feature branch write leaked into main" + ); + + let local_branch = self.success(argv!( + "git", + "--work-dir", + self.work_dir.as_os_str(), + "branch", + "create", + "git-local", + "--from", + "feature-cli" + )); + assert_contains( + &local_branch, + "forkpress: branch git-local ready", + "git branch create local", + ); + assert!( + self.branch_root("git-local").join("wp-load.php").is_file(), + "git-local branch root missing" + ); + + let local_branch_auth = self.failure(argv!( + "git", + "--work-dir", + self.work_dir.as_os_str(), + "branch", + "create", + "git-auth", + "--user", + "jan", + "--password", + "secret" + )); + assert_contains( + &local_branch_auth, + "cow local branch creation does not use --user/--password", + "COW git branch auth rejection", + ); + + let local_branch_unsupported = self.failure(argv!( + "git", + "--work-dir", + self.work_dir.as_os_str(), + "branch", + "create", + "git-unsupported", + "--bogus" + )); + assert_contains( + &local_branch_unsupported, + "unsupported argument for `forkpress git branch create`", + "git branch create unsupported arg", + ); + + let rm = self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "rm", + "git-local" + )); + assert_contains(&rm, "deleted COW branch 'git-local'", "branch rm alias"); + assert!( + !self.branch_root("git-local").exists(), + "git-local was not deleted" + ); + + let alias_create = self.success(argv!( + "branchctl", + "--work-dir", + self.work_dir.as_os_str(), + "create", + "branchctl-alias", + "--from", + "feature-cli" + )); + assert_contains( + &alias_create, + "forkpress: COW cloned 'feature-cli' -> 'branchctl-alias'", + "branchctl create", + ); + let alias_delete = self.success(argv!( + "branchctl", + "--work-dir", + self.work_dir.as_os_str(), + "delete", + "branchctl-alias" + )); + assert_contains( + &alias_delete, + "deleted COW branch 'branchctl-alias'", + "branchctl delete", + ); + + self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "create", + "reset-source" + )); + self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "create", + "reset-target" + )); + self.write_branch_file("reset-source", "wp-content/reset-source.txt", "source\n"); + self.write_branch_file("reset-target", "wp-content/reset-target.txt", "target\n"); + + let reset = self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "reset", + "reset-target", + "--from", + "reset-source" + )); + assert_contains( + &reset, + "reset COW branch 'reset-target' from 'reset-source'", + "branch reset", + ); + assert!( + self.branch_root("reset-target") + .join("wp-content/reset-source.txt") + .is_file(), + "reset target did not receive source file" + ); + assert!( + !self + .branch_root("reset-target") + .join("wp-content/reset-target.txt") + .exists(), + "reset target kept stale target file" + ); + + let rollback = self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "rollback", + "reset-target", + "--from", + "main" + )); + assert_contains( + &rollback, + "reset COW branch 'reset-target' from 'main'", + "branch rollback alias", + ); + assert!( + !self + .branch_root("reset-target") + .join("wp-content/reset-source.txt") + .exists(), + "rollback target kept reset source file" + ); + + let reset_main = self.failure(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "reset", + "main", + "--from", + "feature-cli" + )); + assert_contains( + &reset_main, + "refusing to reset main without --force", + "reset main rejection", + ); + + let reset_self = self.failure(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "reset", + "feature-cli", + "--from", + "feature-cli" + )); + assert_contains( + &reset_self, + "cannot reset a branch from itself", + "reset from itself", + ); + + for branch in ["reset-source", "reset-target"] { + self.success(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "delete", + branch + )); + } + + let delete_main = self.failure(argv!( + "branchctl", + "--work-dir", + self.work_dir.as_os_str(), + "delete", + "main" + )); + assert_contains( + &delete_main, + "cannot delete the main branch", + "delete main rejection", + ); + + let missing_create_name = self.failure(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "create" + )); + assert_contains( + &missing_create_name, + "branch create requires a branch name", + "branch create missing name", + ); + + let invalid_branch = self.failure(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "create", + "bad/slash" + )); + assert_contains( + &invalid_branch, + "invalid branch name", + "invalid branch name", + ); + + let missing_reset_from = self.failure(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "reset", + "feature-cli" + )); + assert_contains( + &missing_reset_from, + "branch reset requires --from ", + "branch reset missing --from", + ); + + let unknown_branch_command = self.failure(argv!( + "branch", + "--work-dir", + self.work_dir.as_os_str(), + "publish", + "feature-cli" + )); + assert_contains( + &unknown_branch_command, + "cow branch subcommand is not implemented yet", + "unknown branch command", + ); + } +} diff --git a/tests/cli/tests/production_cli/git.rs b/tests/cli/tests/production_cli/git.rs new file mode 100644 index 00000000..757f60fc --- /dev/null +++ b/tests/cli/tests/production_cli/git.rs @@ -0,0 +1,209 @@ +use std::fs; + +use crate::harness::Harness; +use crate::support::{ + assert_clean_git, assert_contains, assert_file_contains, git, git_output, step, +}; + +impl Harness { + pub(crate) fn git_checkout_commands(&self) { + step("clone, pull, push, commit, and git passthrough commands"); + + let remote = format!("http://127.0.0.1:{}/site.git", self.port); + let checkout = self.temp.path().join("checkout"); + let clone = self.success(argv!("clone", &remote, checkout.as_os_str())); + assert!( + clone.contains("Cloning into") || clone.trim().is_empty(), + "unexpected clone output:\n{clone}" + ); + assert!( + checkout.join("wordpress/wp-load.php").is_file(), + "clone did not materialize wordpress/wp-load.php" + ); + assert!( + checkout.join("database.sql").is_file(), + "clone did not include database.sql" + ); + assert!( + !checkout + .join("wordpress/wp-content/database/.ht.sqlite") + .exists(), + "clone leaked private SQLite database" + ); + + let remote_clone = self.temp.path().join("remote-name-checkout"); + self.success(argv!( + "clone", + "--remote-name", + "forkpress", + &remote, + remote_clone.as_os_str() + )); + let remotes = git_output(&remote_clone, ["remote"]); + assert_contains(&remotes, "forkpress", "clone --remote-name"); + + self.success(argv!("pull", checkout.as_os_str())); + + git( + &checkout, + ["fetch", "origin", "+refs/heads/*:refs/remotes/origin/*"], + ); + git( + &checkout, + ["checkout", "-B", "feature-cli", "origin/feature-cli"], + ); + fs::write( + checkout.join("wordpress/wp-content/pushed-from-cli.txt"), + "pushed through forkpress push\n", + ) + .expect("failed to write push fixture"); + + let push = self.success(argv!( + "push", + checkout.as_os_str(), + "--message", + "test push command" + )); + assert_contains( + &push, + "feature-cli is now previewable over HTTP", + "push output", + ); + assert_file_contains( + &self + .branch_root("feature-cli") + .join("wp-content/pushed-from-cli.txt"), + "pushed through forkpress push", + ); + assert!( + !self + .branch_root("main") + .join("wp-content/pushed-from-cli.txt") + .exists(), + "push leaked feature file into main" + ); + assert_clean_git(&checkout); + + git( + &checkout, + ["checkout", "-B", "commit-created", "origin/main"], + ); + fs::write( + checkout.join("wordpress/wp-content/commit-created.txt"), + "created through forkpress commit\n", + ) + .expect("failed to write commit fixture"); + let commit = self.success(argv!( + "commit", + checkout.as_os_str(), + "-m", + "create branch through commit alias" + )); + assert_contains( + &commit, + "commit-created is now previewable over HTTP", + "commit alias output", + ); + assert_file_contains( + &self + .branch_root("commit-created") + .join("wp-content/commit-created.txt"), + "created through forkpress commit", + ); + assert_clean_git(&checkout); + + git( + &checkout, + ["checkout", "-B", "default-message", "origin/main"], + ); + fs::write( + checkout.join("wordpress/wp-content/default-message.txt"), + "created with default message\n", + ) + .expect("failed to write default-message fixture"); + let default_push = self.success(argv!("push", checkout.as_os_str())); + assert_contains( + &default_push, + "default-message is now previewable over HTTP", + "push default message output", + ); + assert_file_contains( + &self + .branch_root("default-message") + .join("wp-content/default-message.txt"), + "created with default message", + ); + assert_clean_git(&checkout); + + let missing_remote = self.failure(argv!( + "push", + checkout.as_os_str(), + "--remote-name", + "missing" + )); + assert_contains( + &missing_remote, + "git exited with status", + "push missing remote", + ); + + let git_status = self.success_in(&checkout, argv!("git", "status", "--short")); + assert_eq!( + git_status.trim(), + "", + "git passthrough status was not clean" + ); + } + + pub(crate) fn agent_worktrees(&self) { + step("agents command"); + + let remote = format!("http://127.0.0.1:{}/site.git", self.port); + let agents_dir = self.temp.path().join("agents"); + let output = self.success(argv!( + "agents", + "--work-dir", + self.work_dir.as_os_str(), + "--remote", + &remote, + "--count", + "2", + "--prefix", + "cowagent", + agents_dir.as_os_str() + )); + assert_contains(&output, "2 agent worktrees ready", "agents command output"); + for branch in ["cowagent-1", "cowagent-2"] { + assert!( + self.branch_root(branch).join("wp-load.php").is_file(), + "{branch} branch was not created" + ); + assert!( + agents_dir + .join(branch) + .join("wordpress/wp-load.php") + .is_file(), + "{branch} worktree was not created" + ); + } + + let reuse = self.success(argv!( + "agents", + "--work-dir", + self.work_dir.as_os_str(), + "--remote", + &remote, + "--count", + "2", + "--prefix", + "cowagent", + agents_dir.as_os_str() + )); + assert_contains( + &reuse, + "reusing existing branch cowagent-1", + "agents branch reuse", + ); + assert_contains(&reuse, "reusing existing worktree", "agents worktree reuse"); + } +} diff --git a/tests/cli/tests/production_cli/harness.rs b/tests/cli/tests/production_cli/harness.rs new file mode 100644 index 00000000..e106a86d --- /dev/null +++ b/tests/cli/tests/production_cli/harness.rs @@ -0,0 +1,139 @@ +use assert_cmd::Command; +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command as StdCommand, Stdio}; +use tempfile::TempDir; + +use crate::support::{combined, repo_root, unused_port}; + +pub(crate) struct Harness { + pub(crate) bin: PathBuf, + pub(crate) repo_root: PathBuf, + pub(crate) temp: TempDir, + pub(crate) state_dir: PathBuf, + pub(crate) work_root: PathBuf, + pub(crate) work_dir: PathBuf, + pub(crate) port: u16, +} + +impl Harness { + pub(crate) fn new() -> Self { + let repo_root = repo_root(); + let bin = env::var_os("FORKPRESS_E2E_BIN") + .map(PathBuf::from) + .unwrap_or_else(|| panic!("FORKPRESS_E2E_BIN is required")); + let bin = if bin.is_absolute() { + bin + } else { + repo_root.join(bin) + }; + assert!( + bin.is_file(), + "FORKPRESS_E2E_BIN does not point to a file: {}", + bin.display() + ); + + let temp = TempDir::new().expect("failed to create e2e temp dir"); + let state_dir = temp.path().join("state"); + let work_root = temp.path().join("site"); + let work_dir = work_root.join(".forkpress"); + fs::create_dir_all(&state_dir).expect("failed to create e2e state dir"); + + Self { + bin, + repo_root, + temp, + state_dir, + work_root, + work_dir, + port: unused_port(), + } + } + + pub(crate) fn success(&self, args: I) -> String + where + I: IntoIterator, + S: AsRef, + { + self.success_in(self.temp.path(), args) + } + + pub(crate) fn success_in(&self, cwd: &Path, args: I) -> String + where + I: IntoIterator, + S: AsRef, + { + let mut command = self.command(cwd); + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect::>(); + command.args(&args); + let assert = command.assert().success(); + combined(assert.get_output()) + } + + pub(crate) fn failure(&self, args: I) -> String + where + I: IntoIterator, + S: AsRef, + { + let mut command = self.command(self.temp.path()); + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect::>(); + command.args(&args); + let assert = command.assert().failure(); + combined(assert.get_output()) + } + + fn command(&self, cwd: &Path) -> Command { + let mut command = Command::new(&self.bin); + command + .env("FORKPRESS_STATE_DIR", &self.state_dir) + .env("GIT_TERMINAL_PROMPT", "0") + .env("NO_COLOR", "1") + .current_dir(cwd); + command + } + + pub(crate) fn branch_root(&self, branch: &str) -> PathBuf { + self.work_root.join(branch) + } + + pub(crate) fn write_branch_file(&self, branch: &str, rel: &str, contents: &str) { + let path = self.branch_root(branch).join(rel); + fs::create_dir_all(path.parent().expect("branch file has no parent")) + .expect("failed to create branch fixture parent"); + fs::write(&path, contents).unwrap_or_else(|err| { + panic!("failed to write {}: {err}", path.display()); + }); + } + + pub(crate) fn assert_server_not_listed(&self) { + let list = self.success(argv!("server", "list")); + assert!( + !list.contains(&self.work_dir.display().to_string()), + "server still listed for {}:\n{list}", + self.work_dir.display() + ); + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = StdCommand::new(&self.bin) + .arg("stop") + .arg("--all") + .arg("--timeout") + .arg("5") + .arg("--force") + .env("FORKPRESS_STATE_DIR", &self.state_dir) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} diff --git a/tests/cli/tests/production_cli/lifecycle.rs b/tests/cli/tests/production_cli/lifecycle.rs new file mode 100644 index 00000000..3658a8f1 --- /dev/null +++ b/tests/cli/tests/production_cli/lifecycle.rs @@ -0,0 +1,490 @@ +use std::fs; +use std::process::{Command as StdCommand, Stdio}; +use std::thread; +use std::time::Duration; + +use crate::harness::Harness; +use crate::support::{ + assert_contains, assert_not_contains, combined, host_for_branch, http_get, parse_server_pid, + step, unused_port, wait_for_child, wait_for_registered_server_or_exit, wait_for_tcp, + wait_for_tcp_or_exit, +}; + +impl Harness { + pub(crate) fn no_site_diagnostics(&self) { + step("no-site diagnostics"); + + let missing_work_dir = self.temp.path().join("missing-site/.forkpress"); + + let storage_status = self.success(argv!( + "storage", + "status", + "--work-dir", + missing_work_dir.as_os_str() + )); + assert_contains( + &storage_status, + "site: not initialized", + "storage status", + ); + assert_contains(&storage_status, "mount: none", "storage status"); + + let storage_mount = self.failure(argv!( + "storage", + "mount", + "--work-dir", + missing_work_dir.as_os_str() + )); + assert_contains( + &storage_mount, + "storage mount: no ForkPress site found", + "storage mount", + ); + + let storage_detach = self.success(argv!( + "storage", + "detach", + "--work-dir", + missing_work_dir.as_os_str() + )); + assert_contains( + &storage_detach, + "forkpress: no detachable storage found", + "storage detach", + ); + + let storage_compact = self.failure(argv!( + "storage", + "compact", + "--work-dir", + missing_work_dir.as_os_str() + )); + assert_contains( + &storage_compact, + "storage compact: no ForkPress site found", + "storage compact", + ); + + let branch_list = self.failure(argv!( + "branch", + "--work-dir", + missing_work_dir.as_os_str(), + "list" + )); + assert_contains( + &branch_list, + "branch: no ForkPress site found", + "uninitialized branch list", + ); + + let agents = self.failure(argv!( + "agents", + "--work-dir", + missing_work_dir.as_os_str(), + "--count", + "1" + )); + assert_contains(&agents, "agents: no ForkPress site found", "agents no site"); + + let logs_paths = self.success(argv!( + "logs", + "--work-dir", + missing_work_dir.as_os_str(), + "--file", + "all", + "--paths" + )); + for name in ["wp", "php", "server", "forkpress", "gc"] { + assert_contains(&logs_paths, name, "logs paths"); + } + + let log_tail = self.success(argv!( + "logs", + "--work-dir", + missing_work_dir.as_os_str(), + "--file", + "php-errors", + "-n", + "3" + )); + assert_contains( + &log_tail, + "forkpress: log has not been created yet", + "missing log tail", + ); + + let doctor_storage = self.success(argv!( + "doctor", + "storage", + "--work-dir", + missing_work_dir.as_os_str() + )); + assert_contains( + &doctor_storage, + "ForkPress storage capability report", + "doctor storage", + ); + assert_contains(&doctor_storage, "recommendation:", "doctor storage"); + + let server_list = self.success(argv!("server", "list")); + assert_contains( + &server_list, + "forkpress: no running site servers found", + "server list", + ); + + let server_stop = self.success(argv!( + "server", + "stop", + "--work-dir", + missing_work_dir.as_os_str() + )); + assert_contains( + &server_stop, + "forkpress: no matching running site servers found", + "server stop no match", + ); + + let top_stop = self.success(argv!("stop", "--work-dir", missing_work_dir.as_os_str())); + assert_contains( + &top_stop, + "forkpress: no matching running site servers found", + "stop no match", + ); + + let git_version = self.success(argv!("git", "--version")); + assert_contains(&git_version, "git version", "git passthrough"); + + let not_a_checkout = self.temp.path().join("not-a-checkout"); + fs::create_dir_all(¬_a_checkout).expect("failed to create non-checkout dir"); + let pull = self.failure(argv!("pull", not_a_checkout.as_os_str())); + assert_contains(&pull, "not a git checkout", "pull non-checkout"); + } + + pub(crate) fn init_site(&self) { + step("init COW site"); + + let output = self.success(argv!( + "init", + "--work-dir", + self.work_dir.as_os_str(), + "--admin-password", + "admin", + "--site-title", + "ForkPress E2E", + "--root-host", + "wp.localhost" + )); + assert_contains( + &output, + "COW materialized strategy initialised", + "init output", + ); + assert!(self.work_dir.is_dir(), "work dir missing after init"); + assert!( + self.branch_root("main").join("wp-load.php").is_file(), + "main branch was not materialized" + ); + + let manifest = fs::read_to_string(self.work_dir.join("site.toml")) + .expect("failed to read site manifest"); + assert_contains(&manifest, "strategy = \"cow\"", "site manifest"); + assert!( + manifest.contains("file_view = \"reflink\"") + || manifest.contains("file_view = \"file-copy\"") + || manifest.contains("file_view = \"macos-apfs-sparsebundle\""), + "site manifest did not record a known file view:\n{manifest}" + ); + + let duplicate = self.failure(argv!( + "init", + "--work-dir", + self.work_dir.as_os_str(), + "--admin-password", + "admin" + )); + assert_contains( + &duplicate, + "a ForkPress site already exists", + "duplicate init", + ); + + let storage_status = self.success(argv!( + "storage", + "status", + "--work-dir", + self.work_dir.as_os_str() + )); + assert_contains( + &storage_status, + "ForkPress storage status", + "initialized storage status", + ); + assert_contains( + &storage_status, + "strategy: cow", + "initialized storage status", + ); + + let doctor = self.success(argv!( + "doctor", + "storage", + "--work-dir", + self.work_dir.as_os_str() + )); + assert_contains(&doctor, "strategy: cow", "initialized doctor storage"); + } + + pub(crate) fn server_entrypoints(&mut self) { + step("start/stop/server entrypoints"); + + let start_port = unused_port(); + let start = self.success(argv!( + "start", + "--work-dir", + self.work_dir.as_os_str(), + "--port", + start_port.to_string(), + "--root-host", + "wp.localhost", + "--workers", + "1", + "--background" + )); + assert_contains( + &start, + "forkpress: server started in background", + "start --background", + ); + wait_for_tcp(start_port, Duration::from_secs(30)); + + let duplicate_port = unused_port(); + let duplicate = self.failure(argv!( + "start", + "--work-dir", + self.work_dir.as_os_str(), + "--port", + duplicate_port.to_string(), + "--root-host", + "wp.localhost", + "--workers", + "1", + "--background" + )); + assert_contains(&duplicate, "server already running", "duplicate start"); + + let list = self.success(argv!("server", "list")); + assert_contains(&list, &self.work_dir.display().to_string(), "server list"); + + let stop = self.success(argv!( + "stop", + "--work-dir", + self.work_dir.as_os_str(), + "--timeout", + "20" + )); + assert!( + stop.contains("forkpress: stopped server pid") + || stop.contains("forkpress: no detachable storage found") + || stop.contains("forkpress: detached COW storage") + || stop.contains("forkpress: COW storage is already detached") + || stop.trim().is_empty(), + "unexpected stop output:\n{stop}" + ); + self.assert_server_not_listed(); + + let server_port = unused_port(); + let server_start = self.success(argv!( + "server", + "start", + "--work-dir", + self.work_dir.as_os_str(), + "--port", + server_port.to_string(), + "--root-host", + "wp.localhost", + "--workers", + "1" + )); + assert_contains( + &server_start, + "forkpress: server started in background", + "server start", + ); + wait_for_tcp(server_port, Duration::from_secs(30)); + + let list = self.success(argv!("server", "list")); + let pid = parse_server_pid(&list, &self.work_dir); + let stop_by_pid = self.success(argv!( + "server", + "stop", + "--pid", + pid.to_string(), + "--timeout", + "20" + )); + assert!( + stop_by_pid.contains("forkpress: stopped server pid") + || stop_by_pid.contains("forkpress: no detachable storage found") + || stop_by_pid.contains("forkpress: detached COW storage") + || stop_by_pid.contains("forkpress: COW storage is already detached") + || stop_by_pid.trim().is_empty(), + "unexpected server stop --pid output:\n{stop_by_pid}" + ); + self.assert_server_not_listed(); + + let stop_all = self.success(argv!("server", "stop", "--all", "--timeout", "5")); + assert_contains( + &stop_all, + "forkpress: no matching running site servers found", + "server stop --all without targets", + ); + } + + pub(crate) fn start_main_server_with_serve(&mut self) { + step("serve command keeps site available for command e2e"); + + self.port = unused_port(); + let serve = self.success(argv!( + "serve", + "--work-dir", + self.work_dir.as_os_str(), + "--port", + self.port.to_string(), + "--root-host", + "wp.localhost", + "--workers", + "1" + )); + assert_contains( + &serve, + "forkpress: server started in background", + "serve default background", + ); + wait_for_tcp(self.port, Duration::from_secs(30)); + + let list = self.success(argv!("server", "list")); + assert_contains(&list, &self.work_dir.display().to_string(), "server list"); + + let main = http_get(self.port, &host_for_branch("main", self.port), "/"); + assert_not_contains(&main.body, "Branch not found", "main HTTP response"); + } + + pub(crate) fn stop_main_server(&self) { + step("stop command"); + + self.success(argv!( + "stop", + "--work-dir", + self.work_dir.as_os_str(), + "--timeout", + "20" + )); + self.assert_server_not_listed(); + } + + pub(crate) fn foreground_start_exits_on_sigint(&mut self) { + step("foreground start handles SIGINT"); + + let port = unused_port(); + let mut command = StdCommand::new(&self.bin); + command + .arg("start") + .arg("--work-dir") + .arg(&self.work_dir) + .arg("--port") + .arg(port.to_string()) + .arg("--root-host") + .arg("wp.localhost") + .arg("--workers") + .arg("1") + .env("FORKPRESS_STATE_DIR", &self.state_dir) + .env("GIT_TERMINAL_PROMPT", "0") + .env("NO_COLOR", "1") + .current_dir(self.temp.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command.spawn().expect("failed to spawn foreground start"); + let child_id = child.id(); + wait_for_tcp_or_exit(port, Duration::from_secs(45), &mut child); + wait_for_registered_server_or_exit( + &self.bin, + &self.state_dir, + self.temp.path(), + &self.work_dir, + Duration::from_secs(30), + &mut child, + ); + thread::sleep(Duration::from_secs(1)); + + #[cfg(unix)] + unsafe { + libc::kill(child_id as i32, libc::SIGINT); + } + + #[cfg(not(unix))] + child.kill().expect("failed to stop foreground start"); + + let status = wait_for_child(&mut child, Duration::from_secs(30)); + if status.is_none() { + child.kill().expect("failed to kill stuck foreground start"); + panic!("foreground start did not exit after SIGINT"); + } + + let output = child + .wait_with_output() + .expect("failed to collect start output"); + let combined = combined(&output); + assert!( + output.status.success(), + "foreground start exited unsuccessfully after SIGINT:\n{combined}" + ); + assert_contains(&combined, "Main site:", "foreground start output"); + assert_contains(&combined, "Stopping servers...", "foreground start SIGINT"); + self.assert_server_not_listed(); + } + + pub(crate) fn post_stop_storage_commands(&self) { + step("post-stop storage detach and compact commands"); + + let detach = self.success(argv!( + "storage", + "detach", + "--work-dir", + self.work_dir.as_os_str(), + "--timeout", + "20" + )); + assert!( + detach.contains("no detachable storage found") + || detach.contains("detached COW storage") + || detach.contains("COW storage is already detached"), + "unexpected storage detach output:\n{detach}" + ); + + let compact = self.success(argv!( + "storage", + "compact", + "--work-dir", + self.work_dir.as_os_str(), + "--timeout", + "20" + )); + assert!( + compact.contains("storage file view does not use a compactable sparsebundle") + || compact.contains("compacted COW sparsebundle") + || compact.contains("no APFS sparsebundle found"), + "unexpected storage compact output:\n{compact}" + ); + + let status = self.success(argv!( + "storage", + "status", + "--work-dir", + self.work_dir.as_os_str() + )); + assert_contains(&status, "ForkPress storage status", "final storage status"); + } +} diff --git a/tests/cli/tests/production_cli/logs_storage.rs b/tests/cli/tests/production_cli/logs_storage.rs new file mode 100644 index 00000000..db001eb6 --- /dev/null +++ b/tests/cli/tests/production_cli/logs_storage.rs @@ -0,0 +1,89 @@ +use crate::harness::Harness; +use crate::support::{assert_contains, step}; + +impl Harness { + pub(crate) fn log_commands(&self) { + step("logs command"); + + let paths = self.success(argv!( + "logs", + "--work-dir", + self.work_dir.as_os_str(), + "--file", + "all", + "--paths" + )); + for name in ["wp", "php", "server", "forkpress", "gc"] { + assert_contains(&paths, name, "logs --paths"); + } + + for selection in ["wp", "php", "php-errors", "server", "forkpress", "gc"] { + let output = self.success(argv!( + "logs", + "--work-dir", + self.work_dir.as_os_str(), + "--file", + selection, + "-n", + "5" + )); + assert!( + output.contains("forkpress: log has not been created yet") + || !output.trim().is_empty() + || selection == "server", + "unexpected empty log output for {selection}" + ); + } + + let all = self.success(argv!( + "logs", + "--work-dir", + self.work_dir.as_os_str(), + "--file", + "all", + "-n", + "5" + )); + for header in [ + "==> wp:", + "==> php:", + "==> server:", + "==> forkpress:", + "==> gc:", + ] { + assert_contains(&all, header, "logs --file all"); + } + } + + pub(crate) fn storage_commands(&self) { + step("storage status and mount commands"); + + let status = self.success(argv!( + "storage", + "status", + "--work-dir", + self.work_dir.as_os_str() + )); + for expected in [ + "ForkPress storage status", + "public:", + "storage:", + "lock:", + "leftovers:", + ] { + assert_contains(&status, expected, "storage status"); + } + + let mount = self.success(argv!( + "storage", + "mount", + "--work-dir", + self.work_dir.as_os_str() + )); + assert!( + mount.contains("COW storage mounted") + || mount.contains("does not use a detachable mount"), + "unexpected storage mount output:\n{mount}" + ); + } +} diff --git a/tests/cli/tests/production_cli/support.rs b/tests/cli/tests/production_cli/support.rs new file mode 100644 index 00000000..ef75135b --- /dev/null +++ b/tests/cli/tests/production_cli/support.rs @@ -0,0 +1,290 @@ +use std::ffi::OsStr; +use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command as StdCommand, Output}; +use std::thread; +use std::time::{Duration, Instant}; + +pub(crate) struct HttpResponse { + pub(crate) status: u16, + pub(crate) body: String, +} + +pub(crate) fn step(name: &str) { + println!("==> {name}"); +} + +pub(crate) fn repo_root() -> PathBuf { + let mut dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + loop { + if dir.join("Cargo.toml").is_file() + && dir.join("README.md").is_file() + && dir.join("crates/forkpress-cli").is_dir() + { + return dir; + } + assert!( + dir.pop(), + "failed to locate repository root from {}", + env!("CARGO_MANIFEST_DIR") + ); + } +} + +pub(crate) fn unused_port() -> u16 { + TcpListener::bind(("127.0.0.1", 0)) + .expect("failed to bind ephemeral port") + .local_addr() + .expect("failed to read ephemeral port") + .port() +} + +pub(crate) fn wait_for_tcp(port: u16, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + thread::sleep(Duration::from_millis(100)); + } + panic!("timed out waiting for TCP port {port}"); +} + +pub(crate) fn wait_for_tcp_or_exit(port: u16, timeout: Duration, child: &mut std::process::Child) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return; + } + if let Some(status) = child.try_wait().expect("failed to poll foreground start") { + panic!("foreground start exited before TCP readiness: {status}"); + } + thread::sleep(Duration::from_millis(100)); + } + panic!("timed out waiting for foreground TCP port {port}"); +} + +pub(crate) fn wait_for_registered_server_or_exit( + bin: &Path, + state_dir: &Path, + cwd: &Path, + work_dir: &Path, + timeout: Duration, + child: &mut std::process::Child, +) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + let output = StdCommand::new(bin) + .arg("server") + .arg("list") + .env("FORKPRESS_STATE_DIR", state_dir) + .env("GIT_TERMINAL_PROMPT", "0") + .env("NO_COLOR", "1") + .current_dir(cwd) + .output() + .expect("failed to run server list while waiting for foreground start"); + assert!( + output.status.success(), + "server list failed while waiting for foreground start:\n{}", + combined(&output) + ); + if combined(&output).contains(&work_dir.display().to_string()) { + return; + } + if let Some(status) = child.try_wait().expect("failed to poll foreground start") { + panic!("foreground start exited before server registration: {status}"); + } + thread::sleep(Duration::from_millis(100)); + } + panic!( + "timed out waiting for foreground server registration for {}", + work_dir.display() + ); +} + +pub(crate) fn wait_for_child( + child: &mut std::process::Child, + timeout: Duration, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if let Some(status) = child.try_wait().expect("failed to poll child") { + return Some(status); + } + thread::sleep(Duration::from_millis(100)); + } + None +} + +pub(crate) fn http_get(port: u16, host: &str, path: &str) -> HttpResponse { + let mut stream = TcpStream::connect(("127.0.0.1", port)) + .unwrap_or_else(|err| panic!("failed to connect to HTTP server on {port}: {err}")); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nUser-Agent: forkpress-e2e\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .expect("failed to write HTTP request"); + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .expect("failed to read HTTP response"); + parse_http_response(&bytes) +} + +fn parse_http_response(bytes: &[u8]) -> HttpResponse { + let raw = String::from_utf8_lossy(bytes); + let (headers, body) = raw + .split_once("\r\n\r\n") + .unwrap_or_else(|| panic!("invalid HTTP response:\n{raw}")); + let status = headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse::().ok()) + .unwrap_or_else(|| panic!("invalid HTTP status line:\n{headers}")); + HttpResponse { + status, + body: body.to_string(), + } +} + +pub(crate) fn host_for_branch(branch: &str, port: u16) -> String { + if branch == "main" { + format!("wp.localhost:{port}") + } else { + format!("{branch}.wp.localhost:{port}") + } +} + +pub(crate) fn git(cwd: &Path, args: I) +where + I: IntoIterator, + S: AsRef, +{ + let output = git_command(cwd, args); + assert!( + output.status.success(), + "git command failed in {}\n{}", + cwd.display(), + combined(&output) + ); +} + +pub(crate) fn git_output(cwd: &Path, args: I) -> String +where + I: IntoIterator, + S: AsRef, +{ + let output = git_command(cwd, args); + assert!( + output.status.success(), + "git command failed in {}\n{}", + cwd.display(), + combined(&output) + ); + combined(&output) +} + +fn git_command(cwd: &Path, args: I) -> Output +where + I: IntoIterator, + S: AsRef, +{ + let args = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect::>(); + StdCommand::new("git") + .args(&args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .unwrap_or_else(|err| panic!("failed to run git in {}: {err}", cwd.display())) +} + +pub(crate) fn assert_clean_git(repo: &Path) { + let status = git_output(repo, ["status", "--porcelain"]); + assert_eq!( + status.trim(), + "", + "checkout is dirty after ForkPress command:\n{status}" + ); +} + +pub(crate) fn parse_server_pid(list: &str, work_dir: &Path) -> u32 { + let work_dir = work_dir.display().to_string(); + for line in list.lines().skip(1) { + if line.contains(&work_dir) { + return line + .split('\t') + .next() + .and_then(|pid| pid.parse::().ok()) + .unwrap_or_else(|| panic!("failed to parse server PID from line: {line}")); + } + } + panic!("server list did not include {work_dir}:\n{list}"); +} + +pub(crate) fn top_commands(help: &str) -> Vec<&str> { + let mut commands = Vec::new(); + let mut in_commands = false; + for line in help.lines() { + if line == "Commands:" { + in_commands = true; + continue; + } + if line == "Options:" { + break; + } + if !in_commands { + continue; + } + let Some(command) = line.split_whitespace().next() else { + continue; + }; + if command != "help" { + commands.push(command); + } + } + commands.sort_unstable(); + commands +} + +pub(crate) fn markdown_section<'a>(contents: &'a str, heading: &str) -> Option<&'a str> { + let marker = format!("## {heading}"); + let start = contents.find(&marker)?; + let after_start = start + marker.len(); + let rest = contents.get(after_start..)?; + let end = rest.find("\n## ").unwrap_or(rest.len()); + rest.get(..end) +} + +pub(crate) fn assert_file_contains(path: &Path, needle: &str) { + let contents = fs::read_to_string(path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())); + assert_contains(&contents, needle, &format!("file {}", path.display())); +} + +pub(crate) fn assert_contains(haystack: &str, needle: &str, context: &str) { + assert!( + haystack.contains(needle), + "{context} did not contain {needle:?}\n{haystack}" + ); +} + +pub(crate) fn assert_not_contains(haystack: &str, needle: &str, context: &str) { + assert!( + !haystack.contains(needle), + "{context} unexpectedly contained {needle:?}\n{haystack}" + ); +} + +pub(crate) fn combined(output: &Output) -> String { + let mut combined = String::new(); + combined.push_str(&String::from_utf8_lossy(&output.stdout)); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + combined +} diff --git a/tests/cli/tests/production_cli/surface.rs b/tests/cli/tests/production_cli/surface.rs new file mode 100644 index 00000000..606ff100 --- /dev/null +++ b/tests/cli/tests/production_cli/surface.rs @@ -0,0 +1,215 @@ +use std::ffi::OsString; +use std::fs; + +use crate::harness::Harness; +use crate::support::{assert_contains, assert_not_contains, markdown_section, step, top_commands}; +use crate::{DEV_ONLY_COMMANDS, PRODUCTION_COMMANDS}; + +impl Harness { + pub(crate) fn command_surface(&self) { + step("top-level production command surface"); + + let top_help = self.success(argv!("--help")); + assert_eq!( + top_commands(&top_help), + PRODUCTION_COMMANDS, + "unexpected production command set\n{top_help}" + ); + for command in DEV_ONLY_COMMANDS { + assert_not_contains(&top_help, &format!(" {command}"), "dev command leaked"); + let output = self.failure(argv!(command)); + assert_contains( + &output, + "unrecognized subcommand", + "dev command should not be accepted", + ); + } + + let version = self.success(argv!("--version")); + assert_contains(&version, "forkpress ", "version output"); + + let init_help = self.success(argv!("init", "--help")); + assert_contains(&init_help, "--admin-password ", "init help"); + assert_not_contains(&init_help, "--strategy", "production init help"); + + let start_help = self.success(argv!("start", "--help")); + for expected in [ + "--work-dir ", + "--php-bin ", + "--host ", + "--port ", + "--root-host ", + "--site-title ", + "--workers ", + "--background", + ] { + assert_contains(&start_help, expected, "start help"); + } + assert_not_contains(&start_help, "--gc-interval", "production start help"); + + let serve_help = self.success(argv!("serve", "--help")); + assert_contains(&serve_help, "--foreground", "serve help"); + assert_not_contains(&serve_help, "--gc-interval", "production serve help"); + + let clone_help = self.success(argv!("clone", "--help")); + assert_contains(&clone_help, "--remote-name ", "clone help"); + + let pull_help = self.success(argv!("pull", "--help")); + assert_contains(&pull_help, "[REPO]", "pull help"); + + let push_help = self.success(argv!("push", "--help")); + assert_contains(&push_help, "--message ", "push help"); + assert_contains(&push_help, "--remote-name ", "push help"); + + let commit_help = self.success(argv!("commit", "--help")); + assert_contains(&commit_help, "--message ", "commit help"); + + let logs_help = self.success(argv!("logs", "--help")); + for expected in [ + "--file ", + "--lines ", + "--follow", + "--paths", + "wp:", + "php:", + "server:", + "forkpress:", + "gc:", + "all:", + ] { + assert_contains(&logs_help, expected, "logs help"); + } + + let server_help = self.success(argv!("server", "--help")); + for expected in ["start", "list", "stop"] { + assert_contains(&server_help, expected, "server help"); + } + + let server_stop_help = self.success(argv!("server", "stop", "--help")); + for expected in ["--all", "--pid ", "--timeout ", "--force"] { + assert_contains(&server_stop_help, expected, "server stop help"); + } + + let storage_help = self.success(argv!("storage", "--help")); + for expected in ["status", "mount", "detach", "compact"] { + assert_contains(&storage_help, expected, "storage help"); + } + + let doctor_help = self.success(argv!("doctor", "--help")); + assert_contains(&doctor_help, "storage", "doctor help"); + + for passthrough in ["branch", "branchctl", "git"] { + let help = self.success(argv!(passthrough, "--help")); + assert_contains(&help, "Usage:", &format!("{passthrough} passthrough help")); + } + } + + pub(crate) fn parser_and_early_validation_failures(&self) { + step("parser and early validation failures"); + + let missing_command = self.failure(Vec::::new()); + assert_contains( + &missing_command, + "Usage: forkpress ", + "missing command", + ); + + let init_dev_strategy = self.failure(argv!("init", "--strategy", "cow")); + assert_contains( + &init_dev_strategy, + "unexpected argument '--strategy'", + "production strategy rejection", + ); + + let conflicting_serve_flags = self.failure(argv!("serve", "--background", "--foreground")); + assert_contains( + &conflicting_serve_flags, + "cannot be used with", + "serve conflict", + ); + + for command in ["doctor", "storage", "server"] { + let output = self.failure(argv!(command)); + assert_contains(&output, "Usage:", &format!("{command} missing subcommand")); + } + + let empty_branch = self.failure(argv!("branch")); + assert_contains( + &empty_branch, + "branch requires branchctl arguments", + "empty branch", + ); + + let empty_branchctl = self.failure(argv!("branchctl")); + assert_contains( + &empty_branchctl, + "branch requires branchctl arguments", + "empty branchctl", + ); + + let empty_git = self.failure(argv!("git")); + assert_contains(&empty_git, "git requires arguments", "empty git"); + + let partial_branch_auth = self.failure(argv!( + "git", "branch", "create", "cli-docs", "--user", "jan" + )); + assert_contains( + &partial_branch_auth, + "--user and --password must be passed together", + "branch auth validation", + ); + + let zero_agents = self.failure(argv!("agents", "--count", "0")); + assert_contains( + &zero_agents, + "--count must be greater than zero", + "agents validation", + ); + + let partial_agents_auth = self.failure(argv!("agents", "--user", "jan")); + assert_contains( + &partial_agents_auth, + "--user and --password must be passed together", + "agents auth validation", + ); + + let conflicting_stop_targets = self.failure(argv!("stop", "--all", "--pid", "123")); + assert_contains( + &conflicting_stop_targets, + "pass either --all or --pid, not both", + "stop target validation", + ); + + let follow_all_logs = self.failure(argv!("logs", "--file", "all", "--follow")); + assert_contains( + &follow_all_logs, + "--follow requires one log file", + "logs follow validation", + ); + } + + pub(crate) fn readme_command_docs(&self) { + step("README production command docs"); + + let readme = + fs::read_to_string(self.repo_root.join("README.md")).expect("failed to read README.md"); + let commands = markdown_section(&readme, "Commands") + .unwrap_or_else(|| panic!("README.md is missing a Commands section")); + + for command in PRODUCTION_COMMANDS { + assert_contains( + commands, + &format!("`forkpress {command}"), + "README Commands section", + ); + } + + for command in DEV_ONLY_COMMANDS { + assert_not_contains( + commands, + &format!("`forkpress {command}"), + "README production Commands section", + ); + } + } +} diff --git a/tests/cow/e2e.sh b/tests/cow/e2e.sh index 42171add..478b6180 100755 --- a/tests/cow/e2e.sh +++ b/tests/cow/e2e.sh @@ -123,9 +123,30 @@ log_step "create CLI branch" "$BIN" branch --work-dir "$WORK_DIR" create feature-cow > "$TMP/branch-create.out" grep -F "feature-cow.wp.localhost:$PORT" "$TMP/branch-create.out" >/dev/null test -d "$WORK/feature-cow" +"$BIN" branchctl --work-dir "$WORK_DIR" show feature-cow > "$TMP/branch-show.out" +grep -F "forkpress cow branch feature-cow" "$TMP/branch-show.out" >/dev/null +grep -F " database: " "$TMP/branch-show.out" >/dev/null +grep -F " git ref: " "$TMP/branch-show.out" >/dev/null echo "feature only" > "$WORK/feature-cow/wp-content/forkpress-branch.txt" test ! -e "$WORK/main/wp-content/forkpress-branch.txt" +log_step "create and delete local branch through aliases" +"$BIN" git --work-dir "$WORK_DIR" branch create git-local --from main > "$TMP/git-local-create.out" +grep -F "forkpress: branch git-local ready" "$TMP/git-local-create.out" >/dev/null +test -f "$WORK/git-local/wp-load.php" +"$BIN" branchctl --work-dir "$WORK_DIR" list | grep -F "git-local" >/dev/null +"$BIN" branchctl --work-dir "$WORK_DIR" status git-local > "$TMP/git-local-status.out" +grep -F "forkpress cow branch git-local" "$TMP/git-local-status.out" >/dev/null +"$BIN" branch --work-dir "$WORK_DIR" delete git-local > "$TMP/git-local-delete.out" +grep -F "deleted COW branch 'git-local'" "$TMP/git-local-delete.out" >/dev/null +test ! -e "$WORK/git-local" +if "$BIN" branchctl --work-dir "$WORK_DIR" delete main > "$TMP/delete-main.out" 2>&1; then + echo "delete main unexpectedly succeeded" >&2 + cat "$TMP/delete-main.out" >&2 + exit 1 +fi +grep -F "cannot delete the main branch" "$TMP/delete-main.out" >/dev/null + curl -sS -H "Host: feature-cow.wp.localhost:$PORT" \ "http://127.0.0.1:$PORT/wp-admin/post-new.php" \ -o "$TMP/branch-post-new.html" @@ -173,6 +194,7 @@ test -f "$TMP/checkout/wordpress/wp-load.php" test -f "$TMP/checkout/database.sql" test ! -e "$TMP/checkout/wordpress/wp-content/database/.ht.sqlite" test ! -e "$TMP/checkout/wordpress/wp-content/database/wp-debug.log" +"$BIN" pull "$TMP/checkout" git -C "$TMP/checkout" fetch origin '+refs/heads/*:refs/remotes/origin/*' git -C "$TMP/checkout" checkout -B feature-cow origin/feature-cow @@ -181,7 +203,7 @@ echo "changed through git" > "$TMP/checkout/wordpress/wp-content/cow-git.txt" mkdir -p "$TMP/checkout/wordpress/wp-content/database" echo "private through git" > "$TMP/checkout/wordpress/wp-content/database/pushed-private.txt" log_step "push Git update to existing branch" -"$BIN" commit "$TMP/checkout" --message "test cow git push" +"$BIN" push "$TMP/checkout" --message "test cow git push" test -f "$WORK/feature-cow/wp-content/cow-git.txt" grep -F "changed through git" "$WORK/feature-cow/wp-content/cow-git.txt" >/dev/null test ! -e "$WORK/feature-cow/wp-content/database/pushed-private.txt" @@ -341,4 +363,13 @@ else grep -F "forkpress: storage file view does not use a compactable sparsebundle" "$TMP/storage-compact.out" >/dev/null fi +log_step "stop server through server subcommand" +"$BIN" server stop --work-dir "$WORK_DIR" > "$TMP/server-stop.out" +if "$BIN" server list | grep -F "$WORK_DIR" >/dev/null; then + echo "server still listed after server stop" >&2 + cat "$TMP/server-stop.out" >&2 + "$BIN" server list >&2 || true + exit 1 +fi + echo "PASS cow materialized strategy e2e"