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
46 changes: 16 additions & 30 deletions TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ Tests for `encoders_for()`, `Content::load()`, `Content::encode()`, `Content::sh
| `encode(Gzip)` | Output is valid gzip; decompressed equals input |
| `encode(Brotli)` | Output is valid brotli; decompressed equals input |
| `sha256()` | Same content produces same digest; different content produces different digest |
| `Content::load()` | Reads file bytes; infers MIME from extension |
| `Content::load()` — HTML | Reads file bytes; infers `text/html` from `.html` extension |
| `Content::load()` — PNG | Infers `image/png` from `.png` extension |
| `Content::load()` — unknown | Falls back to `application/octet-stream` for unrecognised extensions |

### 2c. `sync.rs` — Operation Diffing (`build_operations`)

Expand Down Expand Up @@ -111,11 +113,12 @@ The `e2e/` crate uses:
| `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` |
| `serde_json` | Parses JSON output from `icp network status --json` |

**Build script** (`e2e/build.rs`): Before any tests run, `build.rs` compiles `canister.wasm` (`wasm32-unknown-unknown`) and `plugin.wasm` (`wasm32-wasip2`) via nested `cargo build` invocations and exposes their paths as `CANISTER_WASM` / `PLUGIN_WASM` env vars baked into the test binary at compile time.

**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.
1. Copy a committed fixture directory into a `TempDir` and place the pre-built WASMs under `wasms/`.
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` using `-o hex` to obtain binary Candid, decoded into typed structs.
Expand All @@ -137,39 +140,22 @@ Key points:
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.
- **Project root**: `icp` locates `icp.yaml` via a `--project-root-override=<dir>` flag rather than
relying on `$PWD` or `getcwd(2)`. The `icp_cmd(dir)` helper in `e2e/src/lib.rs` sets this flag
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.
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`). This avoids `candid_parser` and dynamic `IDLValue` traversal.

### Test Scenarios

#### Basic Sync Workflow

| Test | Scenario | Asserts |
|---|---|---|
| Initial sync | Empty canister, one HTML + one PNG file | Both keys present; content types correct |
| Basic deploy | Empty canister, one HTML file | `/index.html` present in canister asset list |
| Basic deploy with proxy | Deploy via proxy canister | `/index.html` present after proxy-mode deploy |
| No-op sync | Run sync a second time without changes | Plugin logs "already up to date"; canister state unchanged |
| Content update | Modify HTML file content; re-sync | SHA256 on canister updated; other assets unchanged |
| Asset deletion | Remove a file from the local directory; re-sync | Key deleted from canister; remaining assets intact |
Expand Down Expand Up @@ -241,10 +227,10 @@ The types in `e2e/src/lib.rs` are copied from `assets-sync::canister` — the su
### Layer 3: E2E Tests

- [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`.
`e2e/` crate wired up with `build.rs`, fixture directory, `LocalNetwork` helper, and two smoke tests (`basic_deploy`, `basic_deploy_with_proxy`). New CI job added.

- [ ] **Basic sync E2E tests**
Covers: initial sync, no-op sync, content update, asset deletion, multi-directory sync.
- [x] **Basic sync E2E tests**
Covers: basic deploy, basic deploy with proxy, no-op sync, content update, asset deletion, multi-directory sync.

- [ ] **Encoding policy E2E tests**
Covers: text gets gzip, binary identity-only, gzip skipped when not smaller.
Expand Down
10 changes: 10 additions & 0 deletions assets-sync/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,16 @@ mod tests {
assert_eq!(c.media_type.subtype(), mime::HTML);
}

#[test]
fn load_infers_png_mime() {
use std::io::Write;
let mut f = tempfile::Builder::new().suffix(".png").tempfile().unwrap();
f.write_all(b"\x00").unwrap();
let c = Content::load(f.path()).unwrap();
assert_eq!(c.media_type.type_(), mime::IMAGE);
assert_eq!(c.media_type.subtype(), mime::PNG);
}

#[test]
fn load_unknown_extension_falls_back_to_octet_stream() {
use std::io::Write;
Expand Down
4 changes: 1 addition & 3 deletions e2e/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ publish = false
[dependencies]
assert_cmd.workspace = true
candid.workspace = true
hex.workspace = true
serde.workspace = true
serde_json.workspace = true

[dev-dependencies]
hex.workspace = true
tempfile.workspace = true
69 changes: 67 additions & 2 deletions e2e/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@ use assert_cmd::Command as AssertCmd;
use candid::CandidType;
use serde::Deserialize;
use std::{
fs,
path::{Path, PathBuf},
process::Command,
};

#[derive(CandidType, Clone, Debug, Deserialize)]
#[derive(CandidType, Clone, Debug, Deserialize, PartialEq)]
pub struct AssetEncodingDetails {
pub content_encoding: String,
pub sha256: Option<Vec<u8>>,
}

#[derive(CandidType, Clone, Debug, Deserialize)]
#[derive(CandidType, Clone, Debug, Deserialize, PartialEq)]
pub struct AssetDetails {
pub key: String,
pub encodings: Vec<AssetEncodingDetails>,
Expand Down Expand Up @@ -64,3 +65,67 @@ impl Drop for LocalNetwork {
.output();
}
}

pub 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/basic"`).
/// The returned `TempDir` must be kept alive for the duration of the test.
pub fn setup_project(fixture_path: &str) -> tempfile::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
}

/// Call `list` on the `frontend` canister and return all asset details.
pub fn list_assets(project: &Path) -> Vec<AssetDetails> {
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<AssetDetails>,)>(&bytes)
.expect("failed to decode candid response");
assets
}
131 changes: 0 additions & 131 deletions e2e/tests/basic.rs

This file was deleted.

5 changes: 5 additions & 0 deletions e2e/tests/fixture/multi-dir/dist-a/page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8" /><title>Page A</title></head>
<body><h1>Page from dist-a</h1></body>
</html>
1 change: 1 addition & 0 deletions e2e/tests/fixture/multi-dir/dist-b/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log("app from dist-b");
20 changes: 20 additions & 0 deletions e2e/tests/fixture/multi-dir/icp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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-a
- dist-b
Loading
Loading