diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6140320..75b643f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,18 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Remove pre-installed Rust stable toolchain run: rustup toolchain remove stable 2>/dev/null || true - # No explicit `toolchain` — uses the channel from rust-toolchain.toml. + # No explicit `toolchain` — uses the channel from rust-toolchain.toml, + # including the wasm32-unknown-unknown and wasm32-wasip2 targets needed by `icp deploy`. - name: Install Rust toolchain uses: actions-rust-lang/setup-rust-toolchain@150fca883cd4034361b621bd4e6a9d34e5143606 # v1.15.4 + - name: Install icp CLI + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/icp-cli/releases/latest/download/icp-cli-installer.sh | sh - name: Run tests run: | # https://github.com/rust-lang/cargo/issues/6669 we have to run ALL tests with two commands cargo test --all-targets --no-fail-fast cargo test --doc + env: + ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} fmt: name: cargo fmt diff --git a/.gitignore b/.gitignore index ea8c4bf..dd1f6fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.icp/cache/ diff --git a/Cargo.lock b/Cargo.lock index 33ee89b..145ec44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -101,6 +107,21 @@ dependencies = [ "term", ] +[[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 = "assets-sync" version = "0.1.0" @@ -224,6 +245,17 @@ dependencies = [ "alloc-stdlib", ] +[[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" @@ -646,6 +678,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -677,6 +715,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "e2e" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "candid", + "hex", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "either" version = "1.15.0" @@ -2067,6 +2117,33 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[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 = "pretty" version = "0.12.5" @@ -2632,6 +2709,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "1.0.69" @@ -2824,6 +2907,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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" diff --git a/Cargo.toml b/Cargo.toml index 8834207..fdf7718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["assets-sync", "canister", "ic-certified-assets", "plugin"] +members = ["assets-sync", "canister", "e2e", "ic-certified-assets", "plugin"] resolver = "2" [workspace.package] @@ -10,6 +10,7 @@ license = "Apache-2.0" [workspace.dependencies] anyhow = "1.0.56" +assert_cmd = "2" base64 = "0.13.0" brotli = "8.0.2" candid = "0.10.18" @@ -23,6 +24,7 @@ mime_guess = "2.0" num-traits = "0.2.14" serde = { version = "1.0", features = ["derive"] } serde_bytes = "0.11.5" +serde_json = "1.0" serde_cbor = "0.11.1" sha2 = "0.10.6" wit-bindgen = "0.57.1" diff --git a/TEST.md b/TEST.md index 80565f1..c773afe 100644 --- a/TEST.md +++ b/TEST.md @@ -109,6 +109,8 @@ The `e2e/` crate uses: |---|---| | `assert_cmd` | Invokes `icp` as a subprocess and asserts exit code / stdout | | `tempfile` | Provides throwaway asset directories and `icp.yaml` configs | +| `candid` | Decodes binary Candid responses into typed structs | +| `hex` | Decodes hex output from `icp canister call -o hex` | **Setup per test**: 1. Create a temporary project directory containing: @@ -116,7 +118,50 @@ The `e2e/` crate uses: - An asset directory populated either programmatically by the test or from committed test fixtures. 2. Start a local network with `icp network start -d`; shut it down with `icp network stop` in test cleanup. 3. Run `icp deploy` to install the canister WASM and execute the plugin sync step. -4. Verify the resulting canister state with `icp canister call` (Candid text for both arguments and return values). +4. Verify the resulting canister state with `icp canister call` using `-o hex` to obtain binary Candid, decoded into typed structs. + +#### Network lifecycle and teardown pattern + +`LocalNetwork::start(project_dir)` in `e2e/src/lib.rs` encapsulates the start/stop lifecycle: + +```rust +let _network = LocalNetwork::start(&project); // runs `icp network start -d` +// … test body … +// _network is dropped here → runs `icp network stop` +``` + +Key points: +- **Daemon mode** (`-d`): `icp network start -d` blocks until the replica is ready, then returns. + The replica process continues running in the background. +- **State directory**: the replica writes its state to `.icp/` inside the project directory. + Each test that uses a `tempfile::TempDir` as its project root therefore gets an isolated network state. +- **Teardown on panic**: `LocalNetwork` implements `Drop`, so `icp network stop` is called even when the test panics or the assertion fails. +- **Silent cleanup**: `Drop` ignores errors from `icp network stop` because the replica may have already exited. +- **`$PWD` vs `getcwd`**: on Unix, `icp` reads `$PWD` (not `getcwd(2)`) to locate the project root. + `Command::current_dir()` updates the kernel CWD via `chdir(2)` but does **not** update `$PWD`. + A test binary inherits `$PWD` from the `cargo test` runner (the workspace root), so every `icp` + invocation must also set `.env("PWD", project_dir)`. The `icp_cmd(dir)` helper in `e2e/src/lib.rs` + does this automatically; always use it instead of `Command::new("icp")` directly. + +#### Parsing `icp canister call` output + +Pass `-o hex` to `icp canister call` to receive the raw binary Candid response as a hex string instead of pretty-printed text. Decode it with `hex::decode` and then `candid::decode_args` into the typed structs defined in `e2e/src/lib.rs` (`AssetDetails`, `AssetEncodingDetails`): + +```rust +let output = icp_cmd(&project) + .args(["canister", "call", "frontend", "list", "(record {})", "-o", "hex"]) + .output() + .expect("icp canister call failed"); + +let hex_str = String::from_utf8_lossy(&output.stdout); +let bytes = hex::decode(hex_str.trim()).expect("failed to decode hex response"); +let (assets,) = candid::decode_args::<(Vec,)>(&bytes) + .expect("failed to decode candid response"); + +assert!(assets.iter().any(|a| a.key == "/index.html")); +``` + +The types in `e2e/src/lib.rs` are copied from `assets-sync::canister` — the subset needed to decode canister responses. This avoids `candid_parser` and dynamic `IDLValue` traversal. ### Test Scenarios @@ -195,8 +240,8 @@ The `e2e/` crate uses: ### Layer 3: E2E Tests -- [ ] **E2E infrastructure** - Add `e2e/` crate to workspace. Wire up `assert_cmd` and `tempfile`. Add a skeleton test and a new CI job (`cargo test -p e2e`). Document the `icp network start -d` lifecycle and teardown pattern, and the convention for parsing `icp canister call` output in assertions. +- [x] **E2E infrastructure** + Add `e2e/` crate to workspace. Wire up `assert_cmd` and `tempfile`. Add a skeleton test and a new CI job (`cargo test -p e2e`). Document the `icp network start -d` lifecycle and teardown pattern, and the convention for parsing `icp canister call -o hex` output into typed structs (`AssetDetails`, `AssetEncodingDetails`) via `candid::decode_args`. - [ ] **Basic sync E2E tests** Covers: initial sync, no-op sync, content update, asset deletion, multi-directory sync. diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml new file mode 100644 index 0000000..e16cd78 --- /dev/null +++ b/e2e/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "e2e" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[dependencies] +assert_cmd.workspace = true +candid.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +hex.workspace = true +tempfile.workspace = true diff --git a/e2e/build.rs b/e2e/build.rs new file mode 100644 index 0000000..d976dff --- /dev/null +++ b/e2e/build.rs @@ -0,0 +1,41 @@ +use std::{env, path::Path, path::PathBuf, process::Command}; + +fn main() { + println!("cargo:rerun-if-changed=../canister/src"); + println!("cargo:rerun-if-changed=../plugin/src"); + println!("cargo:rerun-if-changed=../assets-sync/src"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let workspace_root = manifest_dir.parent().expect("e2e/ must have a parent"); + + build_wasm(workspace_root, "canister", "wasm32-unknown-unknown"); + build_wasm(workspace_root, "plugin", "wasm32-wasip2"); + + println!( + "cargo:rustc-env=CANISTER_WASM={}", + workspace_root + .join("target/wasm32-unknown-unknown/release/canister.wasm") + .display() + ); + println!( + "cargo:rustc-env=PLUGIN_WASM={}", + workspace_root + .join("target/wasm32-wasip2/release/plugin.wasm") + .display() + ); +} + +fn build_wasm(workspace_root: &Path, package: &str, target: &str) { + let status = Command::new("cargo") + .args(["build", "-p", package, "--target", target, "--release"]) + // Prevent the nested cargo from inheriting the jobserver file + // descriptors that the outer Cargo passes via CARGO_MAKEFLAGS. + .env_remove("CARGO_MAKEFLAGS") + .current_dir(workspace_root) + .status() + .unwrap_or_else(|e| panic!("failed to spawn cargo build for {package}: {e}")); + assert!( + status.success(), + "cargo build -p {package} --target {target} --release failed" + ); +} diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs new file mode 100644 index 0000000..3369b2f --- /dev/null +++ b/e2e/src/lib.rs @@ -0,0 +1,66 @@ +use assert_cmd::Command as AssertCmd; +use candid::CandidType; +use serde::Deserialize; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +#[derive(CandidType, Clone, Debug, Deserialize)] +pub struct AssetEncodingDetails { + pub content_encoding: String, + pub sha256: Option>, +} + +#[derive(CandidType, Clone, Debug, Deserialize)] +pub struct AssetDetails { + pub key: String, + pub encodings: Vec, + pub content_type: String, +} + +/// Build an `icp` subprocess command rooted at `project_dir`. +/// +/// `current_dir` is set so that build tools spawned by `icp` (e.g. `cargo`) +/// inherit the right working directory. `--project-root-override` is passed +/// explicitly so `icp` locates `icp.yaml` without relying on `$PWD` or +/// `getcwd(2)`. +pub fn icp_cmd(project_dir: &Path) -> AssertCmd { + let mut cmd = AssertCmd::new("icp"); + cmd.current_dir(project_dir) + .arg(format!("--project-root-override={}", project_dir.display())); + cmd +} + +/// RAII guard for a local ICP replica. +/// +/// Starts the replica with `icp network start -d` on construction and stops it +/// with `icp network stop` on drop — even when the test panics. +pub struct LocalNetwork { + dir: PathBuf, +} + +impl LocalNetwork { + /// Start the local replica from `project_dir` and return a guard that stops + /// it when dropped. `project_dir` must contain a valid `icp.yaml`; the + /// replica state is kept in `project_dir/.icp/`. + pub fn start(project_dir: impl Into) -> Self { + let dir = project_dir.into(); + icp_cmd(&dir) + .args(["network", "start", "-d"]) + .assert() + .success(); + LocalNetwork { dir } + } +} + +impl Drop for LocalNetwork { + fn drop(&mut self) { + // Ignore errors: the replica may have already exited or been cleaned up. + let _ = Command::new("icp") + .current_dir(&self.dir) + .arg(format!("--project-root-override={}", self.dir.display())) + .args(["network", "stop"]) + .output(); + } +} diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs new file mode 100644 index 0000000..468dc2c --- /dev/null +++ b/e2e/tests/basic.rs @@ -0,0 +1,131 @@ +use candid::Principal; +use e2e::{icp_cmd, AssetDetails, LocalNetwork}; +use std::{fs, path::Path}; +use tempfile::TempDir; + +fn copy_dir_contents(src: &Path, dst: &Path) -> std::io::Result<()> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let dst_path = dst.join(entry.file_name()); + if ty.is_dir() { + fs::create_dir_all(&dst_path)?; + copy_dir_contents(&entry.path(), &dst_path)?; + } else { + fs::copy(entry.path(), dst_path)?; + } + } + Ok(()) +} + +/// Set up an isolated copy of a fixture in a temporary directory, with +/// pre-built WASM modules placed at `wasms/canister.wasm` and +/// `wasms/plugin.wasm` (paths supplied by the build script). +/// +/// `fixture_path` is relative to the e2e crate root (e.g. `"tests/fixture"`). +/// The returned `TempDir` must be kept alive for the duration of the test. +fn setup_project(fixture_path: &str) -> TempDir { + let crate_root = Path::new(env!("CARGO_MANIFEST_DIR")); + let tmp = tempfile::TempDir::new().expect("failed to create tempdir"); + + copy_dir_contents(&crate_root.join(fixture_path), tmp.path()) + .expect("failed to copy fixture into tempdir"); + + let wasms_dir = tmp.path().join("wasms"); + fs::create_dir_all(&wasms_dir).expect("failed to create wasms/ dir"); + + fs::copy(env!("CANISTER_WASM"), wasms_dir.join("canister.wasm")) + .expect("failed to copy canister.wasm"); + fs::copy(env!("PLUGIN_WASM"), wasms_dir.join("plugin.wasm")) + .expect("failed to copy plugin.wasm"); + + tmp +} + +/// Deploy the test fixture to a local replica and verify that `/index.html` appears +/// in the canister's asset list. +#[test] +fn basic_deploy() { + let tmp = setup_project("tests/fixture/basic"); + let project = tmp.path(); + let _network = LocalNetwork::start(project); + + icp_cmd(project).arg("deploy").assert().success(); + + let stdout = icp_cmd(project) + .args([ + "canister", + "call", + "frontend", + "list", + "(record {})", + "-o", + "hex", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let hex_str = String::from_utf8_lossy(&stdout); + let bytes = hex::decode(hex_str.trim()).expect("failed to decode hex response"); + let (assets,) = candid::decode_args::<(Vec,)>(&bytes) + .expect("failed to decode candid response"); + + assert!( + assets.iter().any(|a| a.key == "/index.html"), + "expected /index.html in canister asset list; got: {assets:#?}", + ); +} + +#[test] +fn basic_deploy_with_proxy() { + let tmp = setup_project("tests/fixture/basic"); + let project = tmp.path(); + let _network = LocalNetwork::start(project); + let network_status = icp_cmd(project) + .args(["network", "status", "--json"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let status_json: serde_json::Value = + serde_json::from_slice(&network_status).expect("failed to parse network status JSON"); + let proxy_id: Principal = status_json["proxy_canister_principal"] + .as_str() + .and_then(|s| Principal::from_text(s).ok()) + .expect("proxy_canister_principal missing or invalid in network status"); + + icp_cmd(project) + .args(["deploy", "--proxy", proxy_id.to_text().as_str()]) + .assert() + .success(); + + let stdout = icp_cmd(project) + .args([ + "canister", + "call", + "frontend", + "list", + "(record {})", + "-o", + "hex", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let hex_str = String::from_utf8_lossy(&stdout); + let bytes = hex::decode(hex_str.trim()).expect("failed to decode hex response"); + let (assets,) = candid::decode_args::<(Vec,)>(&bytes) + .expect("failed to decode candid response"); + + assert!( + assets.iter().any(|a| a.key == "/index.html"), + "expected /index.html in canister asset list; got: {assets:#?}", + ); +} diff --git a/example/dist/index.html b/e2e/tests/fixture/basic/dist/index.html similarity index 100% rename from example/dist/index.html rename to e2e/tests/fixture/basic/dist/index.html diff --git a/example/dist/style.css b/e2e/tests/fixture/basic/dist/style.css similarity index 100% rename from example/dist/style.css rename to e2e/tests/fixture/basic/dist/style.css diff --git a/e2e/tests/fixture/basic/icp.yaml b/e2e/tests/fixture/basic/icp.yaml new file mode 100644 index 0000000..9afec8c --- /dev/null +++ b/e2e/tests/fixture/basic/icp.yaml @@ -0,0 +1,19 @@ +networks: + - name: local + mode: managed + gateway: + port: 0 + +canisters: + - name: frontend + build: + steps: + - type: pre-built + path: wasms/canister.wasm + + sync: + steps: + - type: plugin + path: wasms/plugin.wasm + dirs: + - dist diff --git a/example/.gitignore b/example/.gitignore deleted file mode 100644 index 61c348e..0000000 --- a/example/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.icp/cache/ diff --git a/example/README.md b/example/README.md deleted file mode 100644 index be9a8dd..0000000 --- a/example/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Example - -A minimal project that deploys a certified-assets canister with a static frontend using the sync plugin. - -## Prerequisites - -`icp` must be a build of [icp-cli](https://github.com/dfinity/icp-cli) that includes sync plugin support (not yet available in a released version). - -You also need [`ic-wasm`](https://github.com/dfinity/ic-wasm) on your `PATH` for the canister build step. - -## Running manually - -```sh -# Start a local replica -icp network start -d - -# Build and deploy the canister, then sync assets via the plugin -icp deploy - -# Open the printed frontend canister URL in a browser and verify the page renders correctly - -# Stop the replica when done -icp network stop -``` - -## Proxy deployment path - -To exercise the proxy mode — canister created and owned by the proxy identity, assets uploaded via your user identity with direct calls — replace `icp deploy` with: - -```sh -icp deploy --proxy txyno-ch777-77776-aaaaq-cai -``` - -The plugin will ensure your signing identity has `Commit` permission (routing a `grant_permission` call through the proxy if needed) before uploading assets directly. diff --git a/example/icp.yaml b/example/icp.yaml deleted file mode 100644 index dc4640f..0000000 --- a/example/icp.yaml +++ /dev/null @@ -1,23 +0,0 @@ -canisters: - - name: frontend - build: - steps: - - type: script - commands: - - cargo build -p canister --target wasm32-unknown-unknown --release - - cp ../target/wasm32-unknown-unknown/release/canister.wasm "$ICP_WASM_OUTPUT_PATH" - - - type: script - commands: - - command -v ic-wasm >/dev/null 2>&1 || { echo >&2 "ic-wasm not found. To install ic-wasm, see https://github.com/dfinity/ic-wasm\n"; exit 1; } - - ic-wasm "$ICP_WASM_OUTPUT_PATH" -o "$ICP_WASM_OUTPUT_PATH" metadata candid:service -f ../ic-certified-assets/assets.did --keep-name-section - - sync: - steps: - - type: script - commands: - - cargo build -p plugin --target wasm32-wasip2 --release - - type: plugin - path: ../target/wasm32-wasip2/release/plugin.wasm - dirs: - - dist