From c8fed7f21107bb879df0f6db828f424360553026 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 15:50:08 -0400 Subject: [PATCH 01/10] docs: update TEST.md to reflect actual e2e implementation Co-Authored-By: Claude Sonnet 4.6 --- TEST.md | 35 +++++++++-------------------------- 1 file changed, 9 insertions(+), 26 deletions(-) diff --git a/TEST.md b/TEST.md index c773afe..eff78af 100644 --- a/TEST.md +++ b/TEST.md @@ -111,11 +111,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 +138,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 @@ -241,7 +224,7 @@ 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. From b980a2f07cb4a0b965bd9c4e636e460ef8d645db Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:06:51 -0400 Subject: [PATCH 02/10] refactor: extract e2e test utilities to shared lib.rs Move copy_dir_contents, setup_project into e2e/src/lib.rs so all future test files can share them without duplication. Add list_assets and generate_minimal_png helpers in the same place. Move hex and tempfile from dev-dependencies to dependencies since lib.rs now uses them at compile time. Co-Authored-By: Claude Sonnet 4.6 --- e2e/Cargo.toml | 4 +- e2e/src/lib.rs | 129 +++++++++++++++++++++++++++++++++++++++++++++ e2e/tests/basic.rs | 85 ++--------------------------- 3 files changed, 133 insertions(+), 85 deletions(-) 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..9cc0122 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -2,6 +2,7 @@ use assert_cmd::Command as AssertCmd; use candid::CandidType; use serde::Deserialize; use std::{ + fs, path::{Path, PathBuf}, process::Command, }; @@ -64,3 +65,131 @@ 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 +} + +/// Generate a minimal 1×1 grayscale PNG at runtime without any external crate. +/// +/// Uses a stored (non-compressed) deflate block inside zlib so no compression +/// library is needed. The CRC32 and Adler-32 checksums are computed inline. +pub fn generate_minimal_png() -> Vec { + fn crc32(data: &[u8]) -> u32 { + let mut crc = !0u32; + for &b in data { + let mut v = (crc ^ b as u32) & 0xFF; + for _ in 0..8 { + v = if v & 1 != 0 { 0xEDB88320 ^ (v >> 1) } else { v >> 1 }; + } + crc = (crc >> 8) ^ v; + } + !crc + } + + fn adler32(data: &[u8]) -> u32 { + let (mut s1, mut s2) = (1u32, 0u32); + for &b in data { + s1 = (s1 + b as u32) % 65521; + s2 = (s2 + s1) % 65521; + } + (s2 << 16) | s1 + } + + fn chunk(tag: &[u8; 4], data: &[u8]) -> Vec { + let crc = crc32(&[tag.as_slice(), data].concat()); + let mut v = Vec::new(); + v.extend_from_slice(&(data.len() as u32).to_be_bytes()); + v.extend_from_slice(tag); + v.extend_from_slice(data); + v.extend_from_slice(&crc.to_be_bytes()); + v + } + + // 1×1 grayscale scanline: filter byte 0, pixel value 128 + let scanline: &[u8] = &[0x00, 0x80]; + + // Wrap the scanline in a zlib stored (non-compressed) deflate block. + let len = scanline.len() as u16; + let mut zlib = Vec::new(); + zlib.extend_from_slice(&[0x78, 0x01]); // zlib header (deflate, default compression check) + zlib.push(0x01); // BFINAL=1, BTYPE=00 (stored block) + zlib.extend_from_slice(&len.to_le_bytes()); // LEN + zlib.extend_from_slice(&(!len).to_le_bytes()); // NLEN (one's complement) + zlib.extend_from_slice(scanline); + zlib.extend_from_slice(&adler32(scanline).to_be_bytes()); // zlib checksum + + let mut png = Vec::new(); + png.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG signature + + // IHDR: 1×1, 8-bit grayscale (color_type=0) + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&1u32.to_be_bytes()); // width + ihdr.extend_from_slice(&1u32.to_be_bytes()); // height + ihdr.extend_from_slice(&[8, 0, 0, 0, 0]); // bit_depth=8, color_type=0, compress=0, filter=0, interlace=0 + png.extend_from_slice(&chunk(b"IHDR", &ihdr)); + + png.extend_from_slice(&chunk(b"IDAT", &zlib)); + png.extend_from_slice(&chunk(b"IEND", &[])); + png +} diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs index 468dc2c..73367f1 100644 --- a/e2e/tests/basic.rs +++ b/e2e/tests/basic.rs @@ -1,46 +1,5 @@ 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 -} +use e2e::{icp_cmd, list_assets, setup_project, AssetDetails, LocalNetwork}; /// Deploy the test fixture to a local replica and verify that `/index.html` appears /// in the canister's asset list. @@ -52,26 +11,7 @@ fn basic_deploy() { 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"); + let assets = list_assets(project); assert!( assets.iter().any(|a| a.key == "/index.html"), @@ -103,26 +43,7 @@ fn basic_deploy_with_proxy() { .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"); + let assets = list_assets(project); assert!( assets.iter().any(|a| a.key == "/index.html"), From 304909722c916dff6d585e81c3aa9692d84808d3 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:07:06 -0400 Subject: [PATCH 03/10] test(e2e): add initial_sync test Deploy an empty canister with one HTML file and one runtime-generated PNG (no binary committed); assert both keys are present and content types are correct. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/sync.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 e2e/tests/sync.rs diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs new file mode 100644 index 0000000..9b48d46 --- /dev/null +++ b/e2e/tests/sync.rs @@ -0,0 +1,31 @@ +use e2e::{generate_minimal_png, icp_cmd, list_assets, setup_project, LocalNetwork}; +use std::fs; + +/// Deploy to an empty canister with one HTML file and one runtime-generated PNG. +/// Verifies both keys are present and content types are correct. +#[test] +fn initial_sync() { + let tmp = setup_project("tests/fixture/basic"); + let project = tmp.path(); + + // Write a PNG generated at runtime so no binary file is committed to the repo. + fs::write(project.join("dist/logo.png"), generate_minimal_png()) + .expect("failed to write logo.png"); + + let _network = LocalNetwork::start(project); + icp_cmd(project).arg("deploy").assert().success(); + + let assets = list_assets(project); + + let html = assets + .iter() + .find(|a| a.key == "/index.html") + .expect("/index.html missing from canister after initial sync"); + assert_eq!(html.content_type, "text/html"); + + let png = assets + .iter() + .find(|a| a.key == "/logo.png") + .expect("/logo.png missing from canister after initial sync"); + assert_eq!(png.content_type, "image/png"); +} From 4d9adfcd22be426fce29fd35aca5c942a1c99476 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:07:30 -0400 Subject: [PATCH 04/10] test(e2e): add no_op_sync test Run deploy twice with no file changes; assert the second run reports "up to date" and the canister asset list is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/sync.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs index 9b48d46..f078e13 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -29,3 +29,47 @@ fn initial_sync() { .expect("/logo.png missing from canister after initial sync"); assert_eq!(png.content_type, "image/png"); } + +/// 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 keys_before: Vec = list_assets(project) + .into_iter() + .map(|a| a.key) + .collect(); + + let output = icp_cmd(project) + .arg("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 keys_after: Vec = list_assets(project) + .into_iter() + .map(|a| a.key) + .collect(); + let mut keys_before = keys_before; + keys_before.sort(); + keys_after.sort(); + assert_eq!( + keys_before, keys_after, + "canister asset list should be unchanged after no-op sync", + ); +} From e1129a5b3b279474aef1fab4fdead69f93cde2ec Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:07:51 -0400 Subject: [PATCH 05/10] test(e2e): add content_update test Modify one file's content and re-sync; assert the updated file's identity SHA256 changes on the canister while the untouched file's SHA256 stays the same. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/sync.rs | 51 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs index f078e13..3b7a2bb 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -1,4 +1,4 @@ -use e2e::{generate_minimal_png, icp_cmd, list_assets, setup_project, LocalNetwork}; +use e2e::{generate_minimal_png, icp_cmd, list_assets, setup_project, AssetDetails, LocalNetwork}; use std::fs; /// Deploy to an empty canister with one HTML file and one runtime-generated PNG. @@ -73,3 +73,52 @@ fn no_op_sync() { "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", + ); +} From 2ffec89d725e8df96c8035002fb8d94b64a2f943 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:08:07 -0400 Subject: [PATCH 06/10] test(e2e): add asset_deletion test Remove one file from the local dist directory and re-sync; assert the deleted key is absent from the canister while the remaining asset is still intact. Co-Authored-By: Claude Sonnet 4.6 --- e2e/tests/sync.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs index 3b7a2bb..5349c36 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -122,3 +122,38 @@ fn content_update() { "/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", + ); +} From ddfe87a69653794c21d0be4a466385c169646e36 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:08:50 -0400 Subject: [PATCH 07/10] test(e2e): add multi_directory_sync test Add a multi-dir fixture with two source directories (dist-a, dist-b) containing non-overlapping files. Test deploys from both dirs and asserts all keys appear on the canister with correct leading-slash paths. Also marks the basic sync E2E checklist item complete in TEST.md. Co-Authored-By: Claude Sonnet 4.6 --- TEST.md | 2 +- e2e/tests/fixture/multi-dir/dist-a/page.html | 5 +++++ e2e/tests/fixture/multi-dir/dist-b/app.js | 1 + e2e/tests/fixture/multi-dir/icp.yaml | 20 +++++++++++++++++ e2e/tests/sync.rs | 23 ++++++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/fixture/multi-dir/dist-a/page.html create mode 100644 e2e/tests/fixture/multi-dir/dist-b/app.js create mode 100644 e2e/tests/fixture/multi-dir/icp.yaml diff --git a/TEST.md b/TEST.md index eff78af..b1d42ef 100644 --- a/TEST.md +++ b/TEST.md @@ -226,7 +226,7 @@ Pass `-o hex` to `icp canister call` to receive the raw binary Candid response a - [x] **E2E infrastructure** `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** +- [x] **Basic sync E2E tests** Covers: initial sync, no-op sync, content update, asset deletion, multi-directory sync. - [ ] **Encoding policy E2E tests** 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 index 5349c36..468d1bf 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -157,3 +157,26 @@ fn asset_deletion() { "/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:#?}", + ); +} From 3291a32cc62fdde15a917ff78eb17a968bb59392 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 16:34:47 -0400 Subject: [PATCH 08/10] fix: icp deploy --debug --- e2e/src/lib.rs | 6 +++++- e2e/tests/basic.rs | 2 +- e2e/tests/sync.rs | 18 ++++++++---------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index 9cc0122..b7edfea 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -140,7 +140,11 @@ pub fn generate_minimal_png() -> Vec { for &b in data { let mut v = (crc ^ b as u32) & 0xFF; for _ in 0..8 { - v = if v & 1 != 0 { 0xEDB88320 ^ (v >> 1) } else { v >> 1 }; + v = if v & 1 != 0 { + 0xEDB88320 ^ (v >> 1) + } else { + v >> 1 + }; } crc = (crc >> 8) ^ v; } diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs index 73367f1..ba360f1 100644 --- a/e2e/tests/basic.rs +++ b/e2e/tests/basic.rs @@ -1,5 +1,5 @@ use candid::Principal; -use e2e::{icp_cmd, list_assets, setup_project, AssetDetails, LocalNetwork}; +use e2e::{icp_cmd, list_assets, setup_project, LocalNetwork}; /// Deploy the test fixture to a local replica and verify that `/index.html` appears /// in the canister's asset list. diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs index 468d1bf..6ab5b2d 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -40,13 +40,10 @@ fn no_op_sync() { icp_cmd(project).arg("deploy").assert().success(); - let keys_before: Vec = list_assets(project) - .into_iter() - .map(|a| a.key) - .collect(); + let keys_before: Vec = list_assets(project).into_iter().map(|a| a.key).collect(); let output = icp_cmd(project) - .arg("deploy") + .args(["--debug", "deploy"]) .assert() .success() .get_output() @@ -61,10 +58,7 @@ fn no_op_sync() { "expected 'up to date' in deploy output on second run; got:\n{combined}", ); - let mut keys_after: Vec = list_assets(project) - .into_iter() - .map(|a| a.key) - .collect(); + let mut keys_after: Vec = list_assets(project).into_iter().map(|a| a.key).collect(); let mut keys_before = keys_before; keys_before.sort(); keys_after.sort(); @@ -78,7 +72,11 @@ 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(|a| { + a.encodings + .iter() + .find(|e| e.content_encoding == "identity") + }) .and_then(|e| e.sha256.clone()) } From bd2e7cea9e037098ac4a742e95b6486193604311 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 18:00:22 -0400 Subject: [PATCH 09/10] test: move basic.rs tests into sync.rs, cover PNG MIME in unit test `initial_sync` duplicated `basic_deploy` and used a 60-line hand-rolled PNG generator solely to check content-type detection, which is purely extension-based and belongs in a unit test. Replace it with a `load_infers_png_mime` unit test in `assets-sync/src/content.rs`, delete `generate_minimal_png`, consolidate `basic.rs` into `sync.rs`, and update TEST.md accordingly. Co-Authored-By: Claude Sonnet 4.6 --- TEST.md | 9 +++-- assets-sync/src/content.rs | 10 ++++++ e2e/src/lib.rs | 68 -------------------------------------- e2e/tests/basic.rs | 52 ----------------------------- e2e/tests/sync.rs | 60 ++++++++++++++++++++++----------- 5 files changed, 57 insertions(+), 142 deletions(-) delete mode 100644 e2e/tests/basic.rs diff --git a/TEST.md b/TEST.md index b1d42ef..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`) @@ -152,7 +154,8 @@ Pass `-o hex` to `icp canister call` to receive the raw binary Candid response a | 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 | @@ -227,7 +230,7 @@ Pass `-o hex` to `icp canister call` to receive the raw binary Candid response a `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. - [x] **Basic sync E2E tests** - Covers: initial sync, no-op sync, content update, asset deletion, multi-directory sync. + 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/src/lib.rs b/e2e/src/lib.rs index b7edfea..ab191d1 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -129,71 +129,3 @@ pub fn list_assets(project: &Path) -> Vec { .expect("failed to decode candid response"); assets } - -/// Generate a minimal 1×1 grayscale PNG at runtime without any external crate. -/// -/// Uses a stored (non-compressed) deflate block inside zlib so no compression -/// library is needed. The CRC32 and Adler-32 checksums are computed inline. -pub fn generate_minimal_png() -> Vec { - fn crc32(data: &[u8]) -> u32 { - let mut crc = !0u32; - for &b in data { - let mut v = (crc ^ b as u32) & 0xFF; - for _ in 0..8 { - v = if v & 1 != 0 { - 0xEDB88320 ^ (v >> 1) - } else { - v >> 1 - }; - } - crc = (crc >> 8) ^ v; - } - !crc - } - - fn adler32(data: &[u8]) -> u32 { - let (mut s1, mut s2) = (1u32, 0u32); - for &b in data { - s1 = (s1 + b as u32) % 65521; - s2 = (s2 + s1) % 65521; - } - (s2 << 16) | s1 - } - - fn chunk(tag: &[u8; 4], data: &[u8]) -> Vec { - let crc = crc32(&[tag.as_slice(), data].concat()); - let mut v = Vec::new(); - v.extend_from_slice(&(data.len() as u32).to_be_bytes()); - v.extend_from_slice(tag); - v.extend_from_slice(data); - v.extend_from_slice(&crc.to_be_bytes()); - v - } - - // 1×1 grayscale scanline: filter byte 0, pixel value 128 - let scanline: &[u8] = &[0x00, 0x80]; - - // Wrap the scanline in a zlib stored (non-compressed) deflate block. - let len = scanline.len() as u16; - let mut zlib = Vec::new(); - zlib.extend_from_slice(&[0x78, 0x01]); // zlib header (deflate, default compression check) - zlib.push(0x01); // BFINAL=1, BTYPE=00 (stored block) - zlib.extend_from_slice(&len.to_le_bytes()); // LEN - zlib.extend_from_slice(&(!len).to_le_bytes()); // NLEN (one's complement) - zlib.extend_from_slice(scanline); - zlib.extend_from_slice(&adler32(scanline).to_be_bytes()); // zlib checksum - - let mut png = Vec::new(); - png.extend_from_slice(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); // PNG signature - - // IHDR: 1×1, 8-bit grayscale (color_type=0) - let mut ihdr = Vec::new(); - ihdr.extend_from_slice(&1u32.to_be_bytes()); // width - ihdr.extend_from_slice(&1u32.to_be_bytes()); // height - ihdr.extend_from_slice(&[8, 0, 0, 0, 0]); // bit_depth=8, color_type=0, compress=0, filter=0, interlace=0 - png.extend_from_slice(&chunk(b"IHDR", &ihdr)); - - png.extend_from_slice(&chunk(b"IDAT", &zlib)); - png.extend_from_slice(&chunk(b"IEND", &[])); - png -} diff --git a/e2e/tests/basic.rs b/e2e/tests/basic.rs deleted file mode 100644 index ba360f1..0000000 --- a/e2e/tests/basic.rs +++ /dev/null @@ -1,52 +0,0 @@ -use candid::Principal; -use e2e::{icp_cmd, list_assets, setup_project, LocalNetwork}; - -/// 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:#?}", - ); -} diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs index 6ab5b2d..35f96c5 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -1,33 +1,55 @@ -use e2e::{generate_minimal_png, icp_cmd, list_assets, setup_project, AssetDetails, LocalNetwork}; +use candid::Principal; +use e2e::{icp_cmd, list_assets, setup_project, AssetDetails, LocalNetwork}; use std::fs; -/// Deploy to an empty canister with one HTML file and one runtime-generated PNG. -/// Verifies both keys are present and content types are correct. +/// Deploy the test fixture to a local replica and verify that `/index.html` appears +/// in the canister's asset list. #[test] -fn initial_sync() { +fn basic_deploy() { let tmp = setup_project("tests/fixture/basic"); let project = tmp.path(); - - // Write a PNG generated at runtime so no binary file is committed to the repo. - fs::write(project.join("dist/logo.png"), generate_minimal_png()) - .expect("failed to write logo.png"); - let _network = LocalNetwork::start(project); + icp_cmd(project).arg("deploy").assert().success(); let assets = list_assets(project); - let html = assets - .iter() - .find(|a| a.key == "/index.html") - .expect("/index.html missing from canister after initial sync"); - assert_eq!(html.content_type, "text/html"); + assert!( + assets.iter().any(|a| a.key == "/index.html"), + "expected /index.html in canister asset list; got: {assets:#?}", + ); +} - let png = assets - .iter() - .find(|a| a.key == "/logo.png") - .expect("/logo.png missing from canister after initial sync"); - assert_eq!(png.content_type, "image/png"); +#[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. From 9ed85f0763f38753b85ffa3d9c67a3a9455d0f74 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 7 May 2026 18:11:51 -0400 Subject: [PATCH 10/10] test: strengthen no_op_sync to compare full AssetDetails Compare SHA256s and encodings (not just keys) after the second deploy, so the test catches content or encoding changes that leave the key list unchanged. Co-Authored-By: Claude Sonnet 4.6 --- e2e/src/lib.rs | 4 ++-- e2e/tests/sync.rs | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/e2e/src/lib.rs b/e2e/src/lib.rs index ab191d1..31e3796 100644 --- a/e2e/src/lib.rs +++ b/e2e/src/lib.rs @@ -7,13 +7,13 @@ use std::{ 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, diff --git a/e2e/tests/sync.rs b/e2e/tests/sync.rs index 35f96c5..bdd7791 100644 --- a/e2e/tests/sync.rs +++ b/e2e/tests/sync.rs @@ -62,7 +62,12 @@ fn no_op_sync() { icp_cmd(project).arg("deploy").assert().success(); - let keys_before: Vec = list_assets(project).into_iter().map(|a| a.key).collect(); + 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"]) @@ -80,12 +85,14 @@ fn no_op_sync() { "expected 'up to date' in deploy output on second run; got:\n{combined}", ); - let mut keys_after: Vec = list_assets(project).into_iter().map(|a| a.key).collect(); - let mut keys_before = keys_before; - keys_before.sort(); - keys_after.sort(); + 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!( - keys_before, keys_after, + assets_before, assets_after, "canister asset list should be unchanged after no-op sync", ); }