diff --git a/TEST.md b/TEST.md
index c773afe..8cd9e5d 100644
--- a/TEST.md
+++ b/TEST.md
@@ -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`)
@@ -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.
@@ -137,31 +140,13 @@ 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=
` 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,)>(&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
@@ -169,7 +154,8 @@ The types in `e2e/src/lib.rs` are copied from `assets-sync::canister` — the su
| 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 |
@@ -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.
diff --git a/assets-sync/src/content.rs b/assets-sync/src/content.rs
index 5faa695..98e4244 100644
--- a/assets-sync/src/content.rs
+++ b/assets-sync/src/content.rs
@@ -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;
diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml
index e16cd78..96c0f04 100644
--- a/e2e/Cargo.toml
+++ b/e2e/Cargo.toml
@@ -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
diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs
index 3369b2f..31e3796 100644
--- a/e2e/src/lib.rs
+++ b/e2e/src/lib.rs
@@ -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>,
}
-#[derive(CandidType, Clone, Debug, Deserialize)]
+#[derive(CandidType, Clone, Debug, Deserialize, PartialEq)]
pub struct AssetDetails {
pub key: String,
pub encodings: Vec,
@@ -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 {
+ 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");
+ assets
+}
diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs
deleted file mode 100644
index 468dc2c..0000000
--- a/e2e/tests/basic.rs
+++ /dev/null
@@ -1,131 +0,0 @@
-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/e2e/tests/fixture/multi-dir/dist-a/page.html b/e2e/tests/fixture/multi-dir/dist-a/page.html
new file mode 100644
index 0000000..550afa3
--- /dev/null
+++ b/e2e/tests/fixture/multi-dir/dist-a/page.html
@@ -0,0 +1,5 @@
+
+
+ Page A
+ Page from dist-a
+
diff --git a/e2e/tests/fixture/multi-dir/dist-b/app.js b/e2e/tests/fixture/multi-dir/dist-b/app.js
new file mode 100644
index 0000000..bc3439d
--- /dev/null
+++ b/e2e/tests/fixture/multi-dir/dist-b/app.js
@@ -0,0 +1 @@
+console.log("app from dist-b");
diff --git a/e2e/tests/fixture/multi-dir/icp.yaml b/e2e/tests/fixture/multi-dir/icp.yaml
new file mode 100644
index 0000000..abb5bb0
--- /dev/null
+++ b/e2e/tests/fixture/multi-dir/icp.yaml
@@ -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
diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs
new file mode 100644
index 0000000..bdd7791
--- /dev/null
+++ b/e2e/tests/sync.rs
@@ -0,0 +1,209 @@
+use candid::Principal;
+use e2e::{icp_cmd, list_assets, setup_project, AssetDetails, LocalNetwork};
+use std::fs;
+
+/// 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 assets = list_assets(project);
+
+ 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 assets = list_assets(project);
+
+ assert!(
+ assets.iter().any(|a| a.key == "/index.html"),
+ "expected /index.html in canister asset list; got: {assets:#?}",
+ );
+}
+
+/// Run sync twice without modifying any files.
+/// The second deploy must report "up to date" and must not change canister state.
+#[test]
+fn no_op_sync() {
+ let tmp = setup_project("tests/fixture/basic");
+ let project = tmp.path();
+ let _network = LocalNetwork::start(project);
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let mut assets_before = list_assets(project);
+ assets_before.sort_by(|a, b| a.key.cmp(&b.key));
+ for a in assets_before.iter_mut() {
+ a.encodings
+ .sort_by(|x, y| x.content_encoding.cmp(&y.content_encoding));
+ }
+
+ let output = icp_cmd(project)
+ .args(["--debug", "deploy"])
+ .assert()
+ .success()
+ .get_output()
+ .clone();
+ let combined = format!(
+ "{}\n{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr),
+ );
+ assert!(
+ combined.contains("up to date"),
+ "expected 'up to date' in deploy output on second run; got:\n{combined}",
+ );
+
+ let mut assets_after = list_assets(project);
+ assets_after.sort_by(|a, b| a.key.cmp(&b.key));
+ for a in assets_after.iter_mut() {
+ a.encodings
+ .sort_by(|x, y| x.content_encoding.cmp(&y.content_encoding));
+ }
+ assert_eq!(
+ assets_before, assets_after,
+ "canister asset list should be unchanged after no-op sync",
+ );
+}
+
+fn identity_sha(assets: &[AssetDetails], key: &str) -> Option> {
+ assets
+ .iter()
+ .find(|a| a.key == key)
+ .and_then(|a| {
+ a.encodings
+ .iter()
+ .find(|e| e.content_encoding == "identity")
+ })
+ .and_then(|e| e.sha256.clone())
+}
+
+/// Modify one file's content and re-sync.
+/// The updated file's identity SHA256 must change; the untouched file's must not.
+#[test]
+fn content_update() {
+ let tmp = setup_project("tests/fixture/basic");
+ let project = tmp.path();
+ let _network = LocalNetwork::start(project);
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let assets_before = list_assets(project);
+ let html_sha_before = identity_sha(&assets_before, "/index.html")
+ .expect("/index.html identity sha256 missing before update");
+ let css_sha_before = identity_sha(&assets_before, "/style.css")
+ .expect("/style.css identity sha256 missing before update");
+
+ // Overwrite index.html with clearly different content.
+ fs::write(
+ project.join("dist/index.html"),
+ b"Updated content
",
+ )
+ .expect("failed to overwrite index.html");
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let assets_after = list_assets(project);
+ let html_sha_after = identity_sha(&assets_after, "/index.html")
+ .expect("/index.html identity sha256 missing after update");
+ let css_sha_after = identity_sha(&assets_after, "/style.css")
+ .expect("/style.css identity sha256 missing after update");
+
+ assert_ne!(
+ html_sha_before, html_sha_after,
+ "/index.html sha256 should change after content update",
+ );
+ assert_eq!(
+ css_sha_before, css_sha_after,
+ "/style.css sha256 should not change when file was not modified",
+ );
+}
+
+/// Remove one file from the local directory and re-sync.
+/// The deleted key must disappear from the canister; the remaining key must survive.
+#[test]
+fn asset_deletion() {
+ let tmp = setup_project("tests/fixture/basic");
+ let project = tmp.path();
+ let _network = LocalNetwork::start(project);
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let assets_before = list_assets(project);
+ assert!(
+ assets_before.iter().any(|a| a.key == "/index.html"),
+ "/index.html should be present before deletion",
+ );
+ assert!(
+ assets_before.iter().any(|a| a.key == "/style.css"),
+ "/style.css should be present before deletion",
+ );
+
+ fs::remove_file(project.join("dist/style.css")).expect("failed to remove style.css");
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let assets_after = list_assets(project);
+ assert!(
+ assets_after.iter().any(|a| a.key == "/index.html"),
+ "/index.html should still be present after deleting style.css",
+ );
+ assert!(
+ !assets_after.iter().any(|a| a.key == "/style.css"),
+ "/style.css should be removed from the canister after local deletion",
+ );
+}
+
+/// Configure two source directories with non-overlapping files and sync.
+/// All files from both directories must appear in the canister with the
+/// correct leading-slash keys.
+#[test]
+fn multi_directory_sync() {
+ let tmp = setup_project("tests/fixture/multi-dir");
+ let project = tmp.path();
+ let _network = LocalNetwork::start(project);
+
+ icp_cmd(project).arg("deploy").assert().success();
+
+ let assets = list_assets(project);
+
+ assert!(
+ assets.iter().any(|a| a.key == "/page.html"),
+ "/page.html from dist-a should be present; got: {assets:#?}",
+ );
+ assert!(
+ assets.iter().any(|a| a.key == "/app.js"),
+ "/app.js from dist-b should be present; got: {assets:#?}",
+ );
+}