Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
/target
.icp/cache/
92 changes: 92 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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"
Expand All @@ -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"
Expand Down
51 changes: 48 additions & 3 deletions TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,59 @@ 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:
- `icp.yaml` referencing the workspace-built `canister.wasm` and `plugin.wasm`.
- 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<AssetDetails>,)>(&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

Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions e2e/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions e2e/build.rs
Original file line number Diff line number Diff line change
@@ -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"
);
}
66 changes: 66 additions & 0 deletions e2e/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<u8>>,
}

#[derive(CandidType, Clone, Debug, Deserialize)]
pub struct AssetDetails {
pub key: String,
pub encodings: Vec<AssetEncodingDetails>,
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<PathBuf>) -> 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();
}
}
Loading
Loading