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:#?}", + ); +}