From cd3902dd385215c6971e362d3b7fcae28787a82a Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 6 May 2026 16:50:38 -0400 Subject: [PATCH 1/7] test: add e2e crate with typed Candid decoding Adds the e2e/ workspace crate with LocalNetwork infrastructure and a basic deploy/list test. Canister responses are decoded via -o hex + candid::decode_args into typed structs (AssetDetails, AssetEncodingDetails) copied from assets-sync::canister, removing the candid_parser dependency. Also wires up the e2e CI job and updates TEST.md to reflect the approach. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 24 ++++++++++- Cargo.lock | 91 ++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- TEST.md | 51 ++++++++++++++++++++-- e2e/Cargo.toml | 15 +++++++ e2e/src/lib.rs | 66 +++++++++++++++++++++++++++++ e2e/tests/basic.rs | 55 ++++++++++++++++++++++++ 7 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 e2e/Cargo.toml create mode 100644 e2e/src/lib.rs create mode 100644 e2e/tests/basic.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6140320..833f923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,10 +74,29 @@ jobs: run: | cargo clippy --tests --benches --keep-going -- -D warnings + e2e: + name: cargo test e2e + runs-on: ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Remove pre-installed Rust stable toolchain + run: rustup toolchain remove stable 2>/dev/null || true + # Installs the toolchain 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-installer.sh | sh + - name: Install ic-wasm + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/ic-wasm/releases/latest/download/ic-wasm-installer.sh | sh + - name: Run E2E tests + run: cargo test -p e2e + aggregate: name: ci:required if: ${{ always() }} - needs: [ test, fmt, clippy ] + needs: [ test, fmt, clippy, e2e ] runs-on: ubuntu-24.04 steps: - name: check test result @@ -89,3 +108,6 @@ jobs: - name: check clippy result if: ${{ needs.clippy.result != 'success' }} run: exit 1 + - name: check e2e result + if: ${{ needs.e2e.result != 'success' }} + run: exit 1 diff --git a/Cargo.lock b/Cargo.lock index 33ee89b..b1106b3 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,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "e2e" +version = "0.1.0" +dependencies = [ + "assert_cmd", + "candid", + "hex", + "serde", + "tempfile", +] + [[package]] name = "either" version = "1.15.0" @@ -2067,6 +2116,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 +2708,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 +2906,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..695af1f 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" 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..71d429b --- /dev/null +++ b/e2e/Cargo.toml @@ -0,0 +1,15 @@ +[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 + +[dev-dependencies] +hex.workspace = true +tempfile.workspace = true 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..2494add --- /dev/null +++ b/e2e/tests/basic.rs @@ -0,0 +1,55 @@ +use e2e::{icp_cmd, AssetDetails, LocalNetwork}; +use std::path::PathBuf; + +/// Absolute path to the committed `example/` project at the workspace root. +/// +/// The `icp.yaml` inside that directory knows how to build the canister WASM +/// and plugin WASM with paths relative to the workspace `target/`, so the +/// test can run `icp deploy` directly from that directory without any fixture +/// copying. +fn example_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("e2e/ must have a parent (the workspace root)") + .join("example") +} + +/// Deploy `example/` to a local replica and verify that `/index.html` appears +/// in the canister's asset list. +/// +/// # Prerequisites +/// +/// - `icp` binary on `$PATH` (install from the icp-cli GitHub releases). +/// - `ic-wasm` binary on `$PATH` (used by the `icp deploy` build step). +/// - `wasm32-unknown-unknown` and `wasm32-wasip2` Rust targets installed +/// (handled automatically via `rust-toolchain.toml`). +#[test] +fn example_deploys_successfully() { + let project = example_dir(); + let _network = LocalNetwork::start(&project); + + icp_cmd(&project).arg("deploy").assert().success(); + + 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"), + "expected /index.html in canister asset list; got: {assets:#?}", + ); +} From 7d513c181f843d2e9c4b38d568fd4cc51fb7eb1d Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 6 May 2026 17:00:51 -0400 Subject: [PATCH 2/7] fix(ci): merge e2e into test and correct url --- .github/workflows/ci.yml | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 833f923..9d78c34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,14 @@ 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: Install ic-wasm + run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/ic-wasm/releases/latest/download/ic-wasm-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 @@ -74,29 +79,10 @@ jobs: run: | cargo clippy --tests --benches --keep-going -- -D warnings - e2e: - name: cargo test e2e - runs-on: ubuntu-24.04 - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Remove pre-installed Rust stable toolchain - run: rustup toolchain remove stable 2>/dev/null || true - # Installs the toolchain 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-installer.sh | sh - - name: Install ic-wasm - run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/ic-wasm/releases/latest/download/ic-wasm-installer.sh | sh - - name: Run E2E tests - run: cargo test -p e2e - aggregate: name: ci:required if: ${{ always() }} - needs: [ test, fmt, clippy, e2e ] + needs: [ test, fmt, clippy ] runs-on: ubuntu-24.04 steps: - name: check test result @@ -108,6 +94,3 @@ jobs: - name: check clippy result if: ${{ needs.clippy.result != 'success' }} run: exit 1 - - name: check e2e result - if: ${{ needs.e2e.result != 'success' }} - run: exit 1 From f19c174217dd520ccba38a227ccfdaf751cf8cee Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 6 May 2026 17:08:08 -0400 Subject: [PATCH 3/7] fix(ci): set ICP_CLI_GITHUB_TOKEN --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d78c34..de3dee9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,8 @@ jobs: 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 From 3b1bd7e492e43d8b82630c42caaa970fc48df7cb Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 12:41:36 -0400 Subject: [PATCH 4/7] test: assert canister call success before decoding output Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/basic.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs index 2494add..fc8880a 100644 --- a/e2e/tests/basic.rs +++ b/e2e/tests/basic.rs @@ -30,7 +30,7 @@ fn example_deploys_successfully() { icp_cmd(&project).arg("deploy").assert().success(); - let output = icp_cmd(&project) + let stdout = icp_cmd(&project) .args([ "canister", "call", @@ -40,10 +40,13 @@ fn example_deploys_successfully() { "-o", "hex", ]) - .output() - .expect("icp canister call failed"); + .assert() + .success() + .get_output() + .stdout + .clone(); - let hex_str = String::from_utf8_lossy(&output.stdout); + 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"); From db2f7503729e7672245663e35ad47d1d42157b87 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 13:37:27 -0400 Subject: [PATCH 5/7] test: move example into e2e fixture, build WASMs via build.rs Replace the `example/` directory with a self-contained fixture under `e2e/tests/fixture/basic/`. A new `e2e/build.rs` pre-builds the canister and plugin WASMs so the test copies pre-built binaries into a temp dir rather than invoking `cargo build` (or `ic-wasm`) at deploy time. This removes the `ic-wasm` dependency from CI and from icp.yaml. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ci.yml | 2 - .gitignore | 1 + e2e/build.rs | 41 +++++++++++ e2e/tests/basic.rs | 69 ++++++++++++------- .../tests/fixture/basic}/dist/index.html | 0 .../tests/fixture/basic}/dist/style.css | 0 e2e/tests/fixture/basic/icp.yaml | 13 ++++ example/.gitignore | 1 - example/README.md | 34 --------- example/icp.yaml | 23 ------- 10 files changed, 100 insertions(+), 84 deletions(-) create mode 100644 e2e/build.rs rename {example => e2e/tests/fixture/basic}/dist/index.html (100%) rename {example => e2e/tests/fixture/basic}/dist/style.css (100%) create mode 100644 e2e/tests/fixture/basic/icp.yaml delete mode 100644 example/.gitignore delete mode 100644 example/README.md delete mode 100644 example/icp.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de3dee9..75b643f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,6 @@ jobs: 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: Install ic-wasm - run: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/dfinity/ic-wasm/releases/latest/download/ic-wasm-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 diff --git a/.gitignore b/.gitignore index ea8c4bf..dd1f6fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.icp/cache/ 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/tests/basic.rs b/e2e/tests/basic.rs index fc8880a..a6db794 100644 --- a/e2e/tests/basic.rs +++ b/e2e/tests/basic.rs @@ -1,36 +1,57 @@ use e2e::{icp_cmd, AssetDetails, LocalNetwork}; -use std::path::PathBuf; +use std::{fs, path::Path}; +use tempfile::TempDir; -/// Absolute path to the committed `example/` project at the workspace root. +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). /// -/// The `icp.yaml` inside that directory knows how to build the canister WASM -/// and plugin WASM with paths relative to the workspace `target/`, so the -/// test can run `icp deploy` directly from that directory without any fixture -/// copying. -fn example_dir() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("e2e/ must have a parent (the workspace root)") - .join("example") +/// `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 `example/` to a local replica and verify that `/index.html` appears +/// Deploy the test fixture to a local replica and verify that `/index.html` appears /// in the canister's asset list. -/// -/// # Prerequisites -/// -/// - `icp` binary on `$PATH` (install from the icp-cli GitHub releases). -/// - `ic-wasm` binary on `$PATH` (used by the `icp deploy` build step). -/// - `wasm32-unknown-unknown` and `wasm32-wasip2` Rust targets installed -/// (handled automatically via `rust-toolchain.toml`). #[test] -fn example_deploys_successfully() { - let project = example_dir(); - let _network = LocalNetwork::start(&project); +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(); + icp_cmd(project).arg("deploy").assert().success(); - let stdout = icp_cmd(&project) + let stdout = icp_cmd(project) .args([ "canister", "call", 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..a7f7848 --- /dev/null +++ b/e2e/tests/fixture/basic/icp.yaml @@ -0,0 +1,13 @@ +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 From bd310c781c634612ac951da9a32c98937a05807c Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 13:42:41 -0400 Subject: [PATCH 6/7] test: add basic_deploy_with_proxy e2e test Parse proxy_canister_principal from `icp network status --json` output and deploy with the --proxy flag, verifying assets are served correctly. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 1 + Cargo.toml | 1 + e2e/Cargo.toml | 1 + e2e/tests/basic.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 55 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index b1106b3..145ec44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -723,6 +723,7 @@ dependencies = [ "candid", "hex", "serde", + "serde_json", "tempfile", ] diff --git a/Cargo.toml b/Cargo.toml index 695af1f..fdf7718 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,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/e2e/Cargo.toml b/e2e/Cargo.toml index 71d429b..e16cd78 100644 --- a/e2e/Cargo.toml +++ b/e2e/Cargo.toml @@ -9,6 +9,7 @@ publish = false assert_cmd.workspace = true candid.workspace = true serde.workspace = true +serde_json.workspace = true [dev-dependencies] hex.workspace = true diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs index a6db794..468dc2c 100644 --- a/e2e/tests/basic.rs +++ b/e2e/tests/basic.rs @@ -1,3 +1,4 @@ +use candid::Principal; use e2e::{icp_cmd, AssetDetails, LocalNetwork}; use std::{fs, path::Path}; use tempfile::TempDir; @@ -77,3 +78,54 @@ fn basic_deploy() { "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:#?}", + ); +} From 9d417c5c6ac3e183337cfffafccf527e31961e8d Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 13:52:04 -0400 Subject: [PATCH 7/7] fix(e2e): use random gateway port to avoid inter-test conflicts Concurrent tests both called `icp network start` on port 8000, causing conflicts. Setting `gateway.port: 0` lets each test get an ephemeral port. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/fixture/basic/icp.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/e2e/tests/fixture/basic/icp.yaml b/e2e/tests/fixture/basic/icp.yaml index a7f7848..9afec8c 100644 --- a/e2e/tests/fixture/basic/icp.yaml +++ b/e2e/tests/fixture/basic/icp.yaml @@ -1,3 +1,9 @@ +networks: + - name: local + mode: managed + gateway: + port: 0 + canisters: - name: frontend build: